562 lines
18 KiB
TypeScript
562 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { memo, useState, useCallback, useEffect, useRef, useMemo } from "react";
|
|
import { PanelRoot, PanelBody } from "./panel-primitives";
|
|
import { PlanPanelHeader } from "./task-plan-panel-header";
|
|
import dynamic from "@/lib/routing/client-dynamic";
|
|
import { IconLoader2, IconFileText, IconRobot, IconMessage, IconClick } from "@tabler/icons-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { useTaskPlan } from "@/hooks/domains/session/use-task-plan";
|
|
import { usePlanDraft } from "@/hooks/domains/session/use-plan-draft";
|
|
import { TaskPlanSaveErrorBanner } from "./task-plan-save-error-banner";
|
|
import { useAppStore } from "@/components/state-provider";
|
|
import { PlanSelectionPopover } from "./plan-selection-popover";
|
|
import { usePlanComments } from "@/hooks/domains/comments/use-plan-comments";
|
|
import {
|
|
resolvePlanCommentRunAvailability,
|
|
useRunComment,
|
|
} from "@/hooks/domains/comments/use-run-comment";
|
|
import type { PlanComment } from "@/lib/state/slices/comments";
|
|
import type {
|
|
TextSelection,
|
|
CommentForEditor,
|
|
} from "@/components/editors/tiptap/tiptap-plan-editor";
|
|
import type { Editor } from "@tiptap/core";
|
|
import { PanelSearchBar } from "@/components/search/panel-search-bar";
|
|
import { usePlanFindShortcut } from "./use-plan-find-shortcut";
|
|
import { usePlanSelection } from "./use-plan-selection";
|
|
import { Trans, useTranslation } from "react-i18next";
|
|
import { t } from "@/lib/i18n";
|
|
import { usePlanCommentMigration } from "@/hooks/domains/comments/use-plan-comment-migration";
|
|
import { PlanCommentMigrationNotice } from "./plan-comment-migration-notice";
|
|
|
|
// Dynamic import to avoid SSR issues with TipTap
|
|
const PlanEditor = dynamic(
|
|
() =>
|
|
import("@/components/editors/tiptap/tiptap-plan-editor").then((mod) => mod.TipTapPlanEditor),
|
|
{
|
|
ssr: false,
|
|
loading: () => (
|
|
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
|
|
{t("task:loadingEditor")}
|
|
</div>
|
|
),
|
|
},
|
|
);
|
|
|
|
type TaskPlanPanelProps = {
|
|
taskId: string | null;
|
|
visible?: boolean;
|
|
/** Internal mobile layout offset for the docked formatting strip. */
|
|
mobileBottomOffset?: string;
|
|
};
|
|
|
|
type TaskOwnedEditor = {
|
|
taskId: string | null;
|
|
editor: Editor;
|
|
} | null;
|
|
|
|
export function getAvailableTaskEditor(
|
|
ownedEditor: TaskOwnedEditor,
|
|
selectedTaskId: string | null,
|
|
): Editor | null {
|
|
if (!ownedEditor || ownedEditor.taskId !== selectedTaskId || ownedEditor.editor.isDestroyed) {
|
|
return null;
|
|
}
|
|
return ownedEditor.editor;
|
|
}
|
|
|
|
function useTaskPlanPanelState(taskId: string | null, visible: boolean) {
|
|
const {
|
|
plan,
|
|
isLoading,
|
|
isSaving,
|
|
saveError,
|
|
savePlan,
|
|
revisions,
|
|
isLoadingRevisions,
|
|
loadRevisions,
|
|
loadRevisionContent,
|
|
revertTo,
|
|
previewRevisionId,
|
|
setPreviewRevision,
|
|
comparePair,
|
|
toggleCompareSelection,
|
|
clearComparePair,
|
|
} = useTaskPlan(taskId, { visible });
|
|
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
|
const activeSession = useAppStore((state) =>
|
|
activeSessionId ? (state.taskSessions.items[activeSessionId] ?? null) : null,
|
|
);
|
|
const sessionState = activeSession?.state;
|
|
const isAgentBusy = sessionState === "STARTING" || sessionState === "RUNNING";
|
|
|
|
const editorWrapperRef = useRef<HTMLDivElement>(null);
|
|
const [ownedEditor, setOwnedEditor] = useState<TaskOwnedEditor>(null);
|
|
const editorInstance = getAvailableTaskEditor(ownedEditor, taskId);
|
|
const {
|
|
draftContent,
|
|
setDraftContent,
|
|
editorKey,
|
|
isEditorFocused,
|
|
handleEmptyStateClick,
|
|
hasUnsavedChanges,
|
|
attemptSave,
|
|
} = usePlanDraft({ plan, isSaving, savePlan, editorWrapperRef, taskId, saveError });
|
|
const commentState = usePlanComments(taskId);
|
|
const planCommentMigration = usePlanCommentMigration(taskId);
|
|
const selectionState = usePlanSelection(taskId, plan?.id, commentState);
|
|
|
|
const handleEditorReady = useCallback(
|
|
(editor: Editor) => setOwnedEditor({ taskId, editor }),
|
|
[taskId],
|
|
);
|
|
|
|
const handleCommentDeleted = useCallback(
|
|
(ids: string[]) => {
|
|
for (const id of ids) {
|
|
void commentState.handleDeleteComment(id);
|
|
}
|
|
},
|
|
[commentState],
|
|
);
|
|
|
|
const commentHighlights: CommentForEditor[] = useMemo(
|
|
() =>
|
|
commentState.comments.map((c) => ({
|
|
id: c.id,
|
|
selectedText: c.selectedText,
|
|
from: c.from,
|
|
to: c.to,
|
|
})),
|
|
[commentState.comments],
|
|
);
|
|
|
|
const isAgentCreatingPlan = isAgentBusy && !plan && draftContent.trim() === "";
|
|
|
|
return {
|
|
plan,
|
|
isLoading,
|
|
isSaving,
|
|
saveError,
|
|
savePlan,
|
|
activeSessionId,
|
|
draftContent,
|
|
setDraftContent,
|
|
editorKey,
|
|
isEditorFocused,
|
|
handleEmptyStateClick,
|
|
hasUnsavedChanges,
|
|
attemptSave,
|
|
commentState,
|
|
planCommentMigration,
|
|
selectionState,
|
|
handleEditorReady,
|
|
handleCommentDeleted,
|
|
commentHighlights,
|
|
isAgentBusy,
|
|
isAgentCreatingPlan,
|
|
editorWrapperRef,
|
|
editorInstance,
|
|
revisions,
|
|
isLoadingRevisions,
|
|
loadRevisions,
|
|
loadRevisionContent,
|
|
revertTo,
|
|
previewRevisionId,
|
|
setPreviewRevision,
|
|
comparePair,
|
|
toggleCompareSelection,
|
|
clearComparePair,
|
|
};
|
|
}
|
|
|
|
export const TaskPlanPanel = memo(function TaskPlanPanel({
|
|
taskId,
|
|
visible = true,
|
|
mobileBottomOffset,
|
|
}: TaskPlanPanelProps) {
|
|
const { t } = useTranslation();
|
|
const state = useTaskPlanPanelState(taskId, visible);
|
|
// Ctrl+S to save immediately
|
|
useSaveShortcut({
|
|
hasUnsavedChanges: state.hasUnsavedChanges,
|
|
isSaving: state.isSaving,
|
|
attemptSave: state.attemptSave,
|
|
draftContent: state.draftContent,
|
|
title: state.plan?.title,
|
|
});
|
|
|
|
if (state.isLoading && !state.plan) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
|
<IconLoader2 className="h-5 w-5 animate-spin mr-2" />
|
|
<span className="text-sm">{t("task:loadingPlan")}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!taskId) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
|
<span className="text-sm">{t("task:noTaskSelected")}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return <PlanPanelContent taskId={taskId} state={state} mobileBottomOffset={mobileBottomOffset} />;
|
|
});
|
|
|
|
function PlanPanelContent({
|
|
taskId,
|
|
state,
|
|
mobileBottomOffset,
|
|
}: {
|
|
taskId: string;
|
|
state: ReturnType<typeof useTaskPlanPanelState>;
|
|
mobileBottomOffset?: string;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const { editorWrapperRef, editorInstance, selectionState } = state;
|
|
const { textSelection, setTextSelection } = selectionState;
|
|
// Ctrl+F in-document find (registers a keydown listener on the editor wrapper)
|
|
const planSearch = usePlanFindShortcut(editorWrapperRef, editorInstance);
|
|
return (
|
|
<PanelRoot data-testid="plan-panel">
|
|
<PlanPanelHeader
|
|
taskId={taskId}
|
|
plan={state.plan}
|
|
draftContent={state.draftContent}
|
|
hasUnsavedChanges={state.hasUnsavedChanges}
|
|
activeSessionId={state.activeSessionId ?? null}
|
|
revisions={state.revisions}
|
|
isLoadingRevisions={state.isLoadingRevisions}
|
|
isSaving={state.isSaving}
|
|
isAgentBusy={state.isAgentBusy}
|
|
attemptSave={state.attemptSave}
|
|
onOpenRevisions={state.loadRevisions}
|
|
onRevert={state.revertTo}
|
|
loadRevisionContent={state.loadRevisionContent}
|
|
previewRevisionId={state.previewRevisionId}
|
|
setPreviewRevision={state.setPreviewRevision}
|
|
comparePair={state.comparePair}
|
|
toggleCompareSelection={state.toggleCompareSelection}
|
|
clearComparePair={state.clearComparePair}
|
|
/>
|
|
{state.saveError && <TaskPlanSaveErrorBanner saveError={state.saveError} />}
|
|
<PlanCommentMigrationNotice {...state.planCommentMigration} />
|
|
<PanelBody
|
|
padding={false}
|
|
scroll={false}
|
|
className={cn(
|
|
"relative transition-colors cursor-text",
|
|
state.isAgentBusy && "bg-background",
|
|
)}
|
|
ref={editorWrapperRef}
|
|
onClick={state.handleEmptyStateClick}
|
|
data-panel-kind="plan"
|
|
>
|
|
<PlanEditor
|
|
key={`${taskId}-${state.editorKey}`}
|
|
taskId={taskId}
|
|
value={state.draftContent}
|
|
readOnly={state.isLoading}
|
|
onChange={state.setDraftContent}
|
|
placeholder={t("task:startTypingYourPlan")}
|
|
mobileBottomOffset={mobileBottomOffset}
|
|
onSelectionChange={setTextSelection}
|
|
comments={state.commentHighlights}
|
|
onCommentClick={selectionState.handleCommentHighlightClick}
|
|
onCommentDeleted={state.handleCommentDeleted}
|
|
onEditorReady={state.handleEditorReady}
|
|
/>
|
|
<PlanEmptyState
|
|
isLoading={state.isLoading}
|
|
draftContent={state.draftContent}
|
|
isEditorFocused={state.isEditorFocused}
|
|
isAgentCreatingPlan={state.isAgentCreatingPlan}
|
|
onClick={state.handleEmptyStateClick}
|
|
/>
|
|
{planSearch.isOpen && (
|
|
<PanelSearchBar
|
|
value={planSearch.query}
|
|
onChange={planSearch.setQuery}
|
|
onNext={planSearch.findNext}
|
|
onPrev={planSearch.findPrev}
|
|
onClose={planSearch.close}
|
|
matchInfo={planSearch.matchInfo}
|
|
/>
|
|
)}
|
|
</PanelBody>
|
|
|
|
<PlanSelectionPopoverWrapper
|
|
textSelection={textSelection}
|
|
taskId={taskId}
|
|
commentState={state.commentState}
|
|
editor={editorInstance}
|
|
onClose={selectionState.handleSelectionClose}
|
|
/>
|
|
</PanelRoot>
|
|
);
|
|
}
|
|
|
|
function removeCommentMark(editor: Editor | null, commentId: string) {
|
|
if (!editor || editor.isDestroyed) return;
|
|
const markType = editor.state.schema.marks.commentMark;
|
|
if (!markType) return;
|
|
const { tr } = editor.state;
|
|
tr.removeMark(0, editor.state.doc.content.size, markType.create({ commentId }));
|
|
editor.view.dispatch(tr);
|
|
}
|
|
|
|
function planCommentRunDisabledReason(
|
|
reason: ReturnType<typeof resolvePlanCommentRunAvailability>["reason"],
|
|
) {
|
|
if (reason === "no-primary-session") return t("task:noPrimarySessionForPlanComment");
|
|
if (reason === "primary-session-unavailable") {
|
|
return t("task:primarySessionUnavailableForPlanComment");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function usePlanSelectionCommentActions({
|
|
textSelection,
|
|
commentState,
|
|
editor,
|
|
runComment,
|
|
}: {
|
|
textSelection: TextSelection | null;
|
|
commentState: ReturnType<typeof usePlanComments>;
|
|
editor: Editor | null;
|
|
runComment: ReturnType<typeof useRunComment>["runComment"];
|
|
}) {
|
|
const [runError, setRunError] = useState<string | null>(null);
|
|
const savedRunCommentRef = useRef<{
|
|
owner: string;
|
|
selection: string;
|
|
body: string;
|
|
comment: PlanComment;
|
|
} | null>(null);
|
|
const ownerIdentity = `${commentState.snapshot?.task_id ?? ""}:${commentState.snapshot?.plan_id ?? ""}`;
|
|
const selectionIdentity = textSelection
|
|
? `${textSelection.from ?? ""}:${textSelection.to ?? ""}:${textSelection.text}`
|
|
: "";
|
|
|
|
useEffect(() => {
|
|
savedRunCommentRef.current = null;
|
|
setRunError(null);
|
|
}, [ownerIdentity]);
|
|
|
|
const addCommentAndApplyMark = useCallback(
|
|
async (comment: string, selectedText: string) => {
|
|
const from = textSelection?.from;
|
|
const to = textSelection?.to;
|
|
const saved = await commentState.handleAddComment(comment, selectedText, from, to);
|
|
if (saved && editor && !editor.isDestroyed && from != null && to != null) {
|
|
editor
|
|
.chain()
|
|
.setTextSelection({ from, to })
|
|
.setMark("commentMark", { commentId: saved.id })
|
|
.run();
|
|
}
|
|
return saved;
|
|
},
|
|
[commentState, textSelection, editor],
|
|
);
|
|
|
|
const handleAdd = useCallback(
|
|
async (comment: string, selectedText: string) => {
|
|
setRunError(null);
|
|
return Boolean(await addCommentAndApplyMark(comment, selectedText));
|
|
},
|
|
[addCommentAndApplyMark],
|
|
);
|
|
|
|
const handleAddAndRun = useCallback(
|
|
async (comment: string, selectedText: string) => {
|
|
setRunError(null);
|
|
const previous = savedRunCommentRef.current;
|
|
const current = previous
|
|
? commentState.comments.find((candidate) => candidate.id === previous.comment.id)
|
|
: null;
|
|
let saved =
|
|
previous?.owner === ownerIdentity &&
|
|
previous.selection === selectionIdentity &&
|
|
previous.body === comment.trim() &&
|
|
(current == null ||
|
|
(current.version === previous.comment.version && current.text === previous.comment.text))
|
|
? (current ?? previous.comment)
|
|
: null;
|
|
if (!saved) {
|
|
saved = await addCommentAndApplyMark(comment, selectedText);
|
|
if (!saved) return false;
|
|
savedRunCommentRef.current = {
|
|
owner: ownerIdentity,
|
|
selection: selectionIdentity,
|
|
body: comment.trim(),
|
|
comment: saved,
|
|
};
|
|
}
|
|
try {
|
|
await runComment(saved);
|
|
savedRunCommentRef.current = null;
|
|
return true;
|
|
} catch (error) {
|
|
setRunError(error instanceof Error ? error.message : t("task:failedToRunPlanComment"));
|
|
return false;
|
|
}
|
|
},
|
|
[addCommentAndApplyMark, commentState.comments, ownerIdentity, runComment, selectionIdentity],
|
|
);
|
|
|
|
return { handleAdd, handleAddAndRun, runError };
|
|
}
|
|
|
|
/** Conditional selection popover for adding/editing comments */
|
|
function PlanSelectionPopoverWrapper({
|
|
textSelection,
|
|
taskId,
|
|
commentState,
|
|
editor,
|
|
onClose,
|
|
}: {
|
|
textSelection: TextSelection | null;
|
|
taskId: string | null;
|
|
commentState: ReturnType<typeof usePlanComments>;
|
|
editor: Editor | null;
|
|
onClose: () => void;
|
|
}) {
|
|
const { runComment } = useRunComment({
|
|
sessionId: null,
|
|
taskId,
|
|
});
|
|
const runUnavailableReason = useAppStore(
|
|
(state) => resolvePlanCommentRunAvailability(state, taskId).reason,
|
|
);
|
|
const runDisabledReason = planCommentRunDisabledReason(runUnavailableReason);
|
|
const { handleAdd, handleAddAndRun, runError } = usePlanSelectionCommentActions({
|
|
textSelection,
|
|
commentState,
|
|
editor,
|
|
runComment,
|
|
});
|
|
|
|
if (!textSelection) return null;
|
|
const editingComment = commentState.editingCommentId
|
|
? commentState.comments.find((c) => c.id === commentState.editingCommentId)?.text
|
|
: undefined;
|
|
const onDelete = commentState.editingCommentId
|
|
? async () => {
|
|
const id = commentState.editingCommentId!;
|
|
const deleted = await commentState.handleDeleteComment(id);
|
|
if (deleted) removeCommentMark(editor, id);
|
|
return deleted;
|
|
}
|
|
: undefined;
|
|
return (
|
|
<PlanSelectionPopover
|
|
selectedText={textSelection.text}
|
|
position={textSelection.position}
|
|
onAdd={handleAdd}
|
|
onAddAndRun={editingComment ? undefined : handleAddAndRun}
|
|
onClose={onClose}
|
|
editingComment={editingComment}
|
|
onDelete={onDelete}
|
|
errorMessage={runError || commentState.mutationError}
|
|
runDisabledReason={runDisabledReason}
|
|
/>
|
|
);
|
|
}
|
|
|
|
type SaveShortcutOptions = {
|
|
hasUnsavedChanges: boolean;
|
|
isSaving: boolean;
|
|
attemptSave: (content: string, title?: string) => Promise<unknown>;
|
|
draftContent: string;
|
|
title: string | undefined;
|
|
};
|
|
|
|
/** Ctrl+S save shortcut */
|
|
function useSaveShortcut({
|
|
hasUnsavedChanges,
|
|
isSaving,
|
|
attemptSave,
|
|
draftContent,
|
|
title,
|
|
}: SaveShortcutOptions) {
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
|
e.preventDefault();
|
|
if (hasUnsavedChanges && !isSaving) {
|
|
// An explicit save is the user's escape hatch from a suppressed
|
|
// autosave: it proceeds unconditionally, even resubmitting
|
|
// unchanged content.
|
|
attemptSave(draftContent, title);
|
|
}
|
|
}
|
|
};
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [hasUnsavedChanges, isSaving, attemptSave, draftContent, title]);
|
|
}
|
|
|
|
/** Rich empty state - shows when no content and editor not focused */
|
|
function PlanEmptyState({
|
|
isLoading,
|
|
draftContent,
|
|
isEditorFocused,
|
|
isAgentCreatingPlan,
|
|
onClick,
|
|
}: {
|
|
isLoading: boolean;
|
|
draftContent: string;
|
|
isEditorFocused: boolean;
|
|
isAgentCreatingPlan: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
if (isLoading || draftContent.trim() !== "" || isEditorFocused || isAgentCreatingPlan)
|
|
return null;
|
|
return (
|
|
<div
|
|
className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
|
onClick={onClick}
|
|
>
|
|
<div className="flex flex-col items-center gap-6 max-w-md px-6">
|
|
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-muted/50">
|
|
<IconFileText className="h-6 w-6 text-muted-foreground" />
|
|
</div>
|
|
<div className="text-center">
|
|
<h3 className="text-sm font-medium text-foreground mb-1">
|
|
{t("task:planYourImplementation")}
|
|
</h3>
|
|
<p className="text-xs text-muted-foreground">{t("task:aSharedDocumentForYouAnd")}</p>
|
|
</div>
|
|
<div className="flex flex-col gap-3 w-full">
|
|
<div className="flex items-start gap-3">
|
|
<IconRobot className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
|
<p className="text-xs text-muted-foreground">{t("task:theAgentCanWriteAndUpdate")}</p>
|
|
</div>
|
|
<div className="flex items-start gap-3">
|
|
<IconMessage className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
|
<p className="text-xs text-muted-foreground">
|
|
<Trans i18nKey="task:selectTextAndPressToComment">
|
|
Select text and press{" "}
|
|
<kbd className="px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-mono text-[10px]">
|
|
⌘⇧C
|
|
</kbd>{" "}
|
|
to comment
|
|
</Trans>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground/70">
|
|
<IconClick className="h-3.5 w-3.5" />
|
|
<span>{t("task:clickAnywhereToStartWriting")}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|