Files
paseo/packages/app/src/hooks/use-draft-agent-create-flow.ts
T
Mohamed Boudra 92afb826d9 Add inline review comments to git diff pane (#530)
* Add inline git diff review comments

Adds a review draft store keyed per diff target, inline gutter + thread
rendering in the diff pane, and a generated review attachment that flows
through the composer and agent providers.

* Strip generated review attachments before saving to draft store

Drafts persist only UserComposerAttachment (no review kind), so filter the
auto-generated review attachment out when seeding the draft or restoring
after a failed auto-submit.

* Fix inline review typecheck and test harness drift

* Unslop inline review feature

- Fix stale-closure bug in composer queueMessage: clear drafts for the
  actually-queued attachments, not the latest props
- Drop dead useEffect mirroring initialBody in inline review editor
  (parent already remounts via key)
- Restore typed AgentAttachment signature on renderPromptAttachmentAsText;
  delete schema redispatch helper and drop null-checks across 5 providers
- Inline single-use review prompt formatters and replace marker switch
  with const map
- Drop normalizeComment field-copy redundancy after type guard
- Remove __reviewDraftStoreTestUtils export; test migration via
  rehydrate() public path
- Refactor findTarget to return the target line directly (no non-null
  assertion at call site)
- Drop redundant isGeneratedReviewAttachment helper; inline kind check

* Update review pill to withUnistyles after rebase

Main's unistyles refactor stripped useUnistyles in favor of withUnistyles +
StyleSheet. Convert ReviewAttachmentPill to use ThemedCircleDot + ICON_SIZE so
the icon picks up the foreground-muted mapping like the other pills.

* Fix lint and typecheck after rebase onto main

Resolve drift after main's withUnistyles refactor:
- Composer complexity: dedup voice?.isVoiceSwitching to drop to 20
- Move per-comment Pressable handlers in git-diff-inline-review into
  CommentRow subcomponent so callbacks are stable
- Hoist style/onPress factories to module level (iconButton,
  iconButtonDestructive, ghostButton)
- Memoize style arrays in git-diff-pane review wrappers
- Replace inline {} placeholders with useMemo styles
- review-draft-store: extract applyCommentUpdates to avoid map-spread,
  include input.key in useMemo deps
- Test fixtures: hoist EMPTY_COMMENTS, add buildReviewActions helper

* Drop GitDiffPane default param to keep complexity at 20

After the rebase, main's `enabled = true` default param combined with the
inline review additions pushed cyclomatic complexity to 21. Drop the
default and resolve `enabled !== false` at the call site (comparison
doesn't count toward complexity).

* Reshape inline review behind @/review module

Drop the `generated: true` attachment flag and consolidate inline-review
logic behind a deep `@/review` module so composer and git-diff-pane stop
hand-composing review primitives. Workspace-scoped review snapshots are
now derived at submit/queue time rather than persisted in the draft
store. Composer review references drop from 51 to 4; git-diff-pane drops
~108 lines and now consumes a controller hook + slot components.

* Fix optimistic review attachments
2026-04-30 21:35:58 +08:00

271 lines
8.0 KiB
TypeScript

import { useCallback, useMemo, useReducer } from "react";
import type { ComposerAttachment } from "@/attachments/types";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import {
generateMessageId,
type StreamItem,
type UserMessageImageAttachment,
} from "@/types/stream";
import type { AgentAttachment } from "@server/shared/messages";
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
interface CreateAttempt {
clientMessageId: string;
text: string;
timestamp: Date;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
}
type DraftAgentMachineState =
| { tag: "draft"; errorMessage: string }
| { tag: "creating"; attempt: CreateAttempt };
type DraftAgentMachineEvent =
| { type: "DRAFT_SET_ERROR"; message: string }
| { type: "SUBMIT"; attempt: CreateAttempt }
| { type: "CREATE_FAILED"; message: string };
function assertNever(value: never): never {
throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}
function reducer(
state: DraftAgentMachineState,
event: DraftAgentMachineEvent,
): DraftAgentMachineState {
switch (event.type) {
case "DRAFT_SET_ERROR": {
if (state.tag !== "draft") {
return state;
}
return { ...state, errorMessage: event.message };
}
case "SUBMIT": {
return { tag: "creating", attempt: event.attempt };
}
case "CREATE_FAILED": {
if (state.tag !== "creating") {
return state;
}
return { tag: "draft", errorMessage: event.message };
}
default:
return assertNever(event);
}
}
interface CreateRequestResult<TCreateResult> {
agentId: string | null;
result: TCreateResult;
}
interface SubmitContext {
text: string;
attachments: ComposerAttachment[];
cwd: string;
}
interface CreateRequestContext {
attempt: CreateAttempt;
text: string;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
cwd: string;
}
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
draftId: string;
getPendingServerId: () => string | null;
allowEmptyText?: boolean;
validateBeforeSubmit?: (ctx: SubmitContext) => string | null;
onBeforeSubmit?: (ctx: CreateRequestContext) => void;
onCreateStart?: () => void;
createRequest: (ctx: CreateRequestContext) => Promise<CreateRequestResult<TCreateResult>>;
buildDraftAgent: (attempt: CreateAttempt) => TDraftAgent;
onCreateSuccess: (ctx: { result: TCreateResult; attempt: CreateAttempt }) => Promise<void> | void;
onCreateError?: (error: Error) => void;
}
export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
draftId,
getPendingServerId,
allowEmptyText = false,
validateBeforeSubmit,
onBeforeSubmit,
onCreateStart,
createRequest,
buildDraftAgent,
onCreateSuccess,
onCreateError,
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
const [machine, dispatch] = useReducer(reducer, {
tag: "draft",
errorMessage: "",
} as DraftAgentMachineState);
const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending);
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId);
const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle);
const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear);
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
const isSubmitting = machine.tag === "creating";
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
if (machine.tag !== "creating") {
return EMPTY_STREAM_ITEMS;
}
if (
!machine.attempt.text &&
(!machine.attempt.images || machine.attempt.images.length === 0) &&
(!machine.attempt.attachments || machine.attempt.attachments.length === 0)
) {
return EMPTY_STREAM_ITEMS;
}
return [
{
kind: "user_message",
id: machine.attempt.clientMessageId,
text: machine.attempt.text,
timestamp: machine.attempt.timestamp,
...(machine.attempt.images && machine.attempt.images.length > 0
? { images: machine.attempt.images }
: {}),
...(machine.attempt.attachments && machine.attempt.attachments.length > 0
? { attachments: machine.attempt.attachments }
: {}),
},
];
}, [machine]);
const draftAgent = useMemo<TDraftAgent | null>(() => {
if (machine.tag !== "creating") {
return null;
}
return buildDraftAgent(machine.attempt);
}, [buildDraftAgent, machine]);
const handleCreateFromInput = useCallback(
async ({ text, attachments, cwd }: SubmitContext) => {
if (isSubmitting) {
throw new Error("Already loading");
}
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
const images = wirePayload.images;
const trimmedPrompt = text.trim();
if (!trimmedPrompt && !allowEmptyText) {
const error = new Error("Initial prompt is required");
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
const validationError = validateBeforeSubmit?.({
text: trimmedPrompt,
attachments,
cwd,
});
if (validationError) {
const error = new Error(validationError);
dispatch({ type: "DRAFT_SET_ERROR", message: validationError });
throw error;
}
const pendingServerId = getPendingServerId();
if (!pendingServerId) {
const error = new Error("No host selected");
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
const attempt: CreateAttempt = {
clientMessageId: generateMessageId(),
text: trimmedPrompt,
timestamp: new Date(),
...(images && images.length > 0 ? { images } : {}),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
};
setPendingCreateAttempt({
draftId,
serverId: pendingServerId,
agentId: null,
clientMessageId: attempt.clientMessageId,
text: attempt.text,
timestamp: attempt.timestamp.getTime(),
...(attempt.images && attempt.images.length > 0 ? { images: attempt.images } : {}),
...(attempt.attachments && attempt.attachments.length > 0
? { attachments: attempt.attachments }
: {}),
});
onBeforeSubmit?.({
attempt,
text: trimmedPrompt,
images,
attachments: wirePayload.attachments,
cwd,
});
dispatch({ type: "SUBMIT", attempt });
onCreateStart?.();
try {
const createResult = await createRequest({
attempt,
text: trimmedPrompt,
images,
attachments: wirePayload.attachments,
cwd,
});
if (createResult.agentId) {
updatePendingAgentId({ draftId, agentId: createResult.agentId });
}
await onCreateSuccess({ result: createResult.result, attempt });
} catch (error) {
const resolved = error instanceof Error ? error : new Error("Failed to create agent");
dispatch({ type: "CREATE_FAILED", message: resolved.message });
markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" });
clearPendingCreateAttempt({ draftId });
onCreateError?.(resolved);
throw error;
}
},
[
clearPendingCreateAttempt,
createRequest,
draftId,
getPendingServerId,
isSubmitting,
markPendingCreateLifecycle,
onBeforeSubmit,
onCreateError,
onCreateStart,
onCreateSuccess,
setPendingCreateAttempt,
updatePendingAgentId,
validateBeforeSubmit,
allowEmptyText,
],
);
return {
machine,
formErrorMessage,
isSubmitting,
optimisticStreamItems,
draftAgent,
handleCreateFromInput,
};
}
export type { CreateAttempt as DraftCreateAttempt };