mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 07:12:18 +10:00
feat(agent): adopt AI SDK v7 — crash safety, context pruning, HITL approvals (#3362)
* docs(adr): propose agent AI SDK v7 adoption plan * fix(ai): bind analyzeResume through aiService in service test The test destructured analyzeResume as a named export that does not exist; main was red. * test(agent): keep pure ai helpers real via spread-actual mock factory * feat(agent): add run guards, patch version guard, run wall-clock timeout * feat(agent): validate UI messages at the send boundary * feat(agent): crash-safe draft-row persistence and server-side cancellation * feat(agent): reap stale run claims at boot, on send, and on thread open * feat(agent): fresh-document patch output and tiered context pruning * feat(ai): shared agent tool contracts and message metadata schema * feat(agent): add per-thread review-patches setting with update endpoint * feat(agent): gate resume patches behind hmac-signed tool approval * feat(agent): merge question answers and approval decisions before run claim * feat(agent): approval ui with composed auto-send and fixture-driven tests * feat(agent): usage metadata, tool activity cards, smoother streaming * feat(agent): tool-call repair, input examples, structured step logging * chore(i18n): translate new agent workspace strings across all locales * fix(agent): gate stale-run draft cancellation on winning the claim clear Snapshot streaming drafts before the conditional clear and skip the flip entirely when another reaper or a replacement run already cleared the claim. Also address review nits in eleven locale catalogs. * fix(agent): flip reaped drafts only when their snapshotted state is unchanged * fix(agent): address review findings across run lifecycle, context budget, and approval flow - bind patches to the revision the model read via signed baseUpdatedAt - claim the run before consuming a continuation; recorded-but-unexecuted approvals retry as pending continuations - keep run ownership on stop() until cancellation persists; preserve the claim for the reaper when final persistence fails - estimate tokens without serializing binary attachments (tokenx) and enforce the budget by dropping oldest whole turns - mark crash-recovered patch results as snapshot boundaries; strip /data prefixes at execution time - retry failed continuations without regenerate; mount a single AgentChat; disable response controls on read-only threads; freeze review toggle during runs (client+server) - accumulate usage across continuations and match the SDK's nested usage shape; label-form token strings; reorderable source label; accessible note field; state-neutral web-search label * chore(i18n): translate revised agent strings across all locales * fix(agent): harden baseUpdatedAt validation and address review follow-ups - bundle tokenx in the server runtime dependencies (e2e boot failure) - strict ISO schema for baseUpdatedAt plus loud executor rejection of unparseable values - it-IT source label consistency (Fonte) - prove penultimate-turn retention in the context pruning test * chore(deps): exempt tokenx from knip for the externalized server bundle
This commit is contained in:
@@ -3,7 +3,7 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { ChatCircleDotsIcon, SidebarSimpleIcon, SquaresFourIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
|
||||
@@ -19,6 +19,26 @@ export const Route = createFileRoute("/agent/$threadId")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
// Matches Tailwind's `lg` breakpoint, which this route's two layouts switch on.
|
||||
const DESKTOP_QUERY = "(min-width: 1024px)";
|
||||
|
||||
// Exactly one layout may mount: each AgentChat owns its own useChat state and stream
|
||||
// reconnection, so CSS-hiding a second instance would double-connect streams and expose
|
||||
// stale pending approval controls after a resize.
|
||||
function useIsDesktopLayout() {
|
||||
const [mediaQueryList] = useState(() => (typeof window === "undefined" ? null : window.matchMedia(DESKTOP_QUERY)));
|
||||
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => {
|
||||
if (!mediaQueryList) return () => {};
|
||||
mediaQueryList.addEventListener("change", onStoreChange);
|
||||
return () => mediaQueryList.removeEventListener("change", onStoreChange);
|
||||
},
|
||||
() => mediaQueryList?.matches ?? false,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const { threadId } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -27,6 +47,7 @@ function RouteComponent() {
|
||||
const resumePanelRef = useRef<PanelImperativeHandle | null>(null);
|
||||
const [isThreadsCollapsed, setIsThreadsCollapsed] = useState(false);
|
||||
const [isResumeCollapsed, setIsResumeCollapsed] = useState(false);
|
||||
const isDesktopLayout = useIsDesktopLayout();
|
||||
const { data, isLoading, error } = useQuery(orpc.agent.threads.get.queryOptions({ input: { id: threadId } }));
|
||||
useAgentResumeUpdateSubscription({ resumeId: data?.resume?.id, threadId });
|
||||
|
||||
@@ -79,91 +100,95 @@ function RouteComponent() {
|
||||
|
||||
return (
|
||||
<div className="h-svh min-w-0 overflow-hidden bg-background">
|
||||
<div className="hidden h-full lg:block">
|
||||
<ResizableGroup orientation="horizontal" className="h-full">
|
||||
<ResizablePanel
|
||||
id="threads"
|
||||
panelRef={threadsPanelRef}
|
||||
defaultSize="18%"
|
||||
minSize="240px"
|
||||
maxSize="360px"
|
||||
collapsible
|
||||
collapsedSize="0px"
|
||||
onResize={(size) => setIsThreadsCollapsed(size.inPixels < 24)}
|
||||
>
|
||||
<AgentThreadSidebar activeThreadId={threadId} className={cn(isThreadsCollapsed && "invisible")} />
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle />
|
||||
<ResizablePanel id="chat" defaultSize="52%" minSize="280px">
|
||||
<AgentChat
|
||||
threadId={threadId}
|
||||
initialMessages={data.messages}
|
||||
isReadOnly={data.isReadOnly}
|
||||
readOnlyReason={readOnlyReason}
|
||||
threadStatus={data.thread.status}
|
||||
activeRunId={data.thread.activeRunId}
|
||||
actions={data.actions}
|
||||
onToggleThreads={toggleThreadsPanel}
|
||||
onToggleResume={toggleResumePanel}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle />
|
||||
<ResizablePanel
|
||||
id="resume"
|
||||
panelRef={resumePanelRef}
|
||||
defaultSize="30%"
|
||||
minSize="340px"
|
||||
maxSize="70%"
|
||||
collapsible
|
||||
collapsedSize="0px"
|
||||
onResize={(size) => setIsResumeCollapsed(size.inPixels < 24)}
|
||||
>
|
||||
<div className={cn("h-full", isResumeCollapsed && "invisible")}>
|
||||
{isDesktopLayout ? (
|
||||
<div className="h-full">
|
||||
<ResizableGroup orientation="horizontal" className="h-full">
|
||||
<ResizablePanel
|
||||
id="threads"
|
||||
panelRef={threadsPanelRef}
|
||||
defaultSize="18%"
|
||||
minSize="240px"
|
||||
maxSize="360px"
|
||||
collapsible
|
||||
collapsedSize="0px"
|
||||
onResize={(size) => setIsThreadsCollapsed(size.inPixels < 24)}
|
||||
>
|
||||
<AgentThreadSidebar activeThreadId={threadId} className={cn(isThreadsCollapsed && "invisible")} />
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle />
|
||||
<ResizablePanel id="chat" defaultSize="52%" minSize="280px">
|
||||
<AgentChat
|
||||
threadId={threadId}
|
||||
initialMessages={data.messages}
|
||||
isReadOnly={data.isReadOnly}
|
||||
readOnlyReason={readOnlyReason}
|
||||
threadStatus={data.thread.status}
|
||||
reviewPatches={data.thread.reviewPatches}
|
||||
activeRunId={data.thread.activeRunId}
|
||||
actions={data.actions}
|
||||
onToggleThreads={toggleThreadsPanel}
|
||||
onToggleResume={toggleResumePanel}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle />
|
||||
<ResizablePanel
|
||||
id="resume"
|
||||
panelRef={resumePanelRef}
|
||||
defaultSize="30%"
|
||||
minSize="340px"
|
||||
maxSize="70%"
|
||||
collapsible
|
||||
collapsedSize="0px"
|
||||
onResize={(size) => setIsResumeCollapsed(size.inPixels < 24)}
|
||||
>
|
||||
<div className={cn("h-full", isResumeCollapsed && "invisible")}>
|
||||
<ResumePane resume={data.resume} />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizableGroup>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-w-0 flex-col">
|
||||
<div className="shrink-0 border-b p-2">
|
||||
<Tabs value={mobileTab} onValueChange={setMobileTab}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="threads">
|
||||
<SidebarSimpleIcon />
|
||||
<Trans>Threads</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="chat">
|
||||
<ChatCircleDotsIcon />
|
||||
<Trans>Chat</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="resume">
|
||||
<SquaresFourIcon />
|
||||
<Trans>Resume</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
|
||||
<div className={cn("h-full min-w-0", mobileTab !== "threads" && "hidden")}>
|
||||
<AgentThreadSidebar activeThreadId={threadId} className="border-e-0" />
|
||||
</div>
|
||||
<div className={cn("h-full min-w-0", mobileTab !== "chat" && "hidden")}>
|
||||
<AgentChat
|
||||
threadId={threadId}
|
||||
initialMessages={data.messages}
|
||||
isReadOnly={data.isReadOnly}
|
||||
readOnlyReason={readOnlyReason}
|
||||
threadStatus={data.thread.status}
|
||||
reviewPatches={data.thread.reviewPatches}
|
||||
activeRunId={data.thread.activeRunId}
|
||||
actions={data.actions}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn("h-full min-w-0", mobileTab !== "resume" && "hidden")}>
|
||||
<ResumePane resume={data.resume} />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizableGroup>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full min-w-0 flex-col lg:hidden">
|
||||
<div className="shrink-0 border-b p-2">
|
||||
<Tabs value={mobileTab} onValueChange={setMobileTab}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="threads">
|
||||
<SidebarSimpleIcon />
|
||||
<Trans>Threads</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="chat">
|
||||
<ChatCircleDotsIcon />
|
||||
<Trans>Chat</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="resume">
|
||||
<SquaresFourIcon />
|
||||
<Trans>Resume</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
|
||||
<div className={cn("h-full min-w-0", mobileTab !== "threads" && "hidden")}>
|
||||
<AgentThreadSidebar activeThreadId={threadId} className="border-e-0" />
|
||||
</div>
|
||||
<div className={cn("h-full min-w-0", mobileTab !== "chat" && "hidden")}>
|
||||
<AgentChat
|
||||
threadId={threadId}
|
||||
initialMessages={data.messages}
|
||||
isReadOnly={data.isReadOnly}
|
||||
readOnlyReason={readOnlyReason}
|
||||
threadStatus={data.thread.status}
|
||||
activeRunId={data.thread.activeRunId}
|
||||
actions={data.actions}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn("h-full min-w-0", mobileTab !== "resume" && "hidden")}>
|
||||
<ResumePane resume={data.resume} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
// Backend-free approval flow: a scripted ChatTransport from @shadcn/helpers drives useChat through
|
||||
// halt (approval-requested) → user decision → composed sendAutomaticallyWhen → continuation.
|
||||
import type { AgentUIMessage } from "@reactive-resume/ai/tools/agent-tool-contracts";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { createChat } from "@shadcn/helpers/ai-sdk";
|
||||
import { lastAssistantMessageIsCompleteWithApprovalResponses, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
||||
|
||||
function buildScriptedChat() {
|
||||
return createChat<AgentUIMessage>()
|
||||
.user("Tighten my summary")
|
||||
.assistant(({ writer }) => {
|
||||
writer.text("I would like to apply this edit.", { mode: "instant" });
|
||||
writer.tool("apply_resume_patch", {
|
||||
toolCallId: "call-1",
|
||||
approvalId: "approval-1",
|
||||
needsApproval: true,
|
||||
input: {
|
||||
title: "Tighten summary",
|
||||
operations: [{ op: "replace", path: "/sections/summary/content", value: "Impact-driven engineer" }],
|
||||
},
|
||||
// With needsApproval, output means "stream this after approval"; denial streams
|
||||
// tool-output-denied automatically.
|
||||
output: {
|
||||
actionId: "action-1",
|
||||
resumeId: "resume-1",
|
||||
title: "Tighten summary",
|
||||
operations: [{ op: "replace", path: "/sections/summary/content", value: "Impact-driven engineer" }],
|
||||
appliedUpdatedAt: "2026-08-20T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
})
|
||||
.assistant(({ writer, toolCall }) => {
|
||||
writer.text(toolCall?.denied ? "Understood, I left the resume unchanged." : "Done — the edit is applied.", {
|
||||
mode: "instant",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
type HarnessProps = {
|
||||
chat: ReturnType<typeof buildScriptedChat>;
|
||||
decision: { approved: boolean; reason?: string };
|
||||
};
|
||||
|
||||
// Minimal stand-in for AgentChat's wiring: same composed auto-send, same approval response call.
|
||||
// AgentChat itself is deliberately not refactored to accept a transport prop for tests.
|
||||
function ApprovalHarness({ chat, decision }: HarnessProps) {
|
||||
const { messages, sendMessage, addToolApprovalResponse } = useChat<AgentUIMessage>({
|
||||
transport: chat.transport({ delayMs: undefined }),
|
||||
sendAutomaticallyWhen: (options) =>
|
||||
lastAssistantMessageIsCompleteWithToolCalls(options) ||
|
||||
lastAssistantMessageIsCompleteWithApprovalResponses(options),
|
||||
});
|
||||
|
||||
const pendingApproval = messages
|
||||
.flatMap((message) => message.parts)
|
||||
.find((part) => "state" in part && part.state === "approval-requested") as
|
||||
| { approval?: { id: string } }
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => sendMessage({ text: "Tighten my summary" })}>
|
||||
send
|
||||
</button>
|
||||
{pendingApproval?.approval ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void addToolApprovalResponse({ id: pendingApproval.approval?.id ?? "", ...decision })}
|
||||
>
|
||||
respond
|
||||
</button>
|
||||
) : null}
|
||||
<output>
|
||||
{messages
|
||||
.flatMap((message) => message.parts)
|
||||
.map((part) => ("state" in part && typeof part.state === "string" ? part.state : part.type))
|
||||
.join(",")}
|
||||
</output>
|
||||
<pre>
|
||||
{messages.map((message) => message.parts.map((part) => ("text" in part ? part.text : "")).join(" ")).join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("agent approval flow (scripted transport)", () => {
|
||||
it("halts on approval-requested, then approves and streams the continuation with the tool output", async () => {
|
||||
render(<ApprovalHarness chat={buildScriptedChat()} decision={{ approved: true }} />);
|
||||
|
||||
screen.getByRole("button", { name: "send" }).click();
|
||||
await waitFor(() => expect(screen.getByRole("status").textContent).toContain("approval-requested"));
|
||||
|
||||
screen.getByRole("button", { name: "respond" }).click();
|
||||
|
||||
// Composed sendAutomaticallyWhen fires the continuation without a manual send.
|
||||
await waitFor(() => expect(screen.getByText(/Done — the edit is applied/)).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("status").textContent).toContain("output-available"));
|
||||
});
|
||||
|
||||
it("denies: the call ends output-denied and the continuation reflects the denial", async () => {
|
||||
render(<ApprovalHarness chat={buildScriptedChat()} decision={{ approved: false, reason: "Wrong section" }} />);
|
||||
|
||||
screen.getByRole("button", { name: "send" }).click();
|
||||
await waitFor(() => expect(screen.getByRole("status").textContent).toContain("approval-requested"));
|
||||
|
||||
screen.getByRole("button", { name: "respond" }).click();
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/left the resume unchanged/)).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("status").textContent).toContain("output-denied"));
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { AgentUIMessage } from "@reactive-resume/ai/tools/agent-tool-contracts";
|
||||
import type { UIMessage, UIMessageChunk } from "ai";
|
||||
import type * as React from "react";
|
||||
import type { RouterOutput } from "@/libs/orpc/client";
|
||||
import type { PatchApprovalResponse } from "./patch-approval-card";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -23,11 +25,12 @@ import {
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
||||
import { lastAssistantMessageIsCompleteWithApprovalResponses, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
||||
import { m } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { agentMessageMetadataSchema } from "@reactive-resume/ai/tools/agent-tool-contracts";
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentContent,
|
||||
@@ -39,6 +42,7 @@ import { Bubble, BubbleContent } from "@reactive-resume/ui/components/bubble";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
@@ -73,6 +77,8 @@ import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { getOrpcErrorMessage } from "@/libs/error-message";
|
||||
import { client, orpc, streamClient } from "@/libs/orpc/client";
|
||||
import { attachmentIdsFromTransportBody, buildAgentChatSubmission } from "../-helpers/chat-attachments";
|
||||
import { OperationRow, PatchApprovalCard } from "./patch-approval-card";
|
||||
import { ToolPartCard } from "./tool-part-card";
|
||||
|
||||
type AgentThreadDetail = RouterOutput["agent"]["threads"]["get"];
|
||||
type AgentAction = AgentThreadDetail["actions"][number];
|
||||
@@ -103,13 +109,16 @@ type FileAttachmentProps = {
|
||||
type AskUserQuestionProps = {
|
||||
part: UIMessage["parts"][number];
|
||||
answer: string | null;
|
||||
disabled?: boolean;
|
||||
onAnswer: (toolCallId: string, answer: string) => void;
|
||||
};
|
||||
|
||||
type MessagePartProps = {
|
||||
part: UIMessage["parts"][number];
|
||||
isUser: boolean;
|
||||
isReadOnly: boolean;
|
||||
onAnswer: (toolCallId: string, answer: string) => void;
|
||||
onApprovalRespond: (response: PatchApprovalResponse) => void;
|
||||
onRevert: (actionId: string) => void;
|
||||
isReverting: boolean;
|
||||
actionsById: Map<string, AgentAction>;
|
||||
@@ -117,7 +126,9 @@ type MessagePartProps = {
|
||||
|
||||
type ChatMessageProps = {
|
||||
message: UIMessage;
|
||||
isReadOnly: boolean;
|
||||
onAnswer: (toolCallId: string, answer: string) => void;
|
||||
onApprovalRespond: (response: PatchApprovalResponse) => void;
|
||||
onRevert: (actionId: string) => void;
|
||||
isReverting: boolean;
|
||||
actionsById: Map<string, AgentAction>;
|
||||
@@ -129,6 +140,7 @@ export type AgentChatProps = {
|
||||
isReadOnly: boolean;
|
||||
readOnlyReason: "archived" | "missing" | null;
|
||||
threadStatus: string;
|
||||
reviewPatches: boolean;
|
||||
activeRunId: string | null;
|
||||
actions: AgentAction[];
|
||||
onToggleThreads?: () => void;
|
||||
@@ -149,6 +161,7 @@ type AgentChatMessagesProps = {
|
||||
isStreaming: boolean;
|
||||
messages: UIMessage[];
|
||||
onAnswer: (toolCallId: string, answer: string) => void;
|
||||
onApprovalRespond: (response: PatchApprovalResponse) => void;
|
||||
onRevert: (actionId: string) => void;
|
||||
onRetry: () => void;
|
||||
onStarterSelect: (prompt: string) => void;
|
||||
@@ -158,12 +171,17 @@ type AgentChatHeaderProps = {
|
||||
isArchived: boolean;
|
||||
isArchivePending: boolean;
|
||||
isDeletePending: boolean;
|
||||
isUpdatePending: boolean;
|
||||
isStreaming: boolean;
|
||||
reviewPatches: boolean;
|
||||
threadTokenTotal: number;
|
||||
onArchive: () => void;
|
||||
onCopyConversation: () => void;
|
||||
onCopyConversationJson: () => void;
|
||||
onDelete: () => void;
|
||||
onClose?: () => void;
|
||||
onToggleResume?: () => void;
|
||||
onToggleReviewPatches: (reviewPatches: boolean) => void;
|
||||
onToggleThreads?: () => void;
|
||||
};
|
||||
|
||||
@@ -263,9 +281,21 @@ function PatchToolCard({ part, action, onRevert, isReverting }: PatchToolCardPro
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-words rounded border bg-background p-3 font-mono text-[0.7rem] leading-relaxed">
|
||||
{rawPayload}
|
||||
</pre>
|
||||
{operations.length > 0 ? (
|
||||
<ul className="max-h-48 space-y-1 overflow-auto rounded border bg-background p-2">
|
||||
{operations.map((operation, index) => (
|
||||
<OperationRow key={`${String((operation as { path?: unknown }).path)}-${index}`} operation={operation} />
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<details>
|
||||
<summary className="cursor-pointer text-muted-foreground/70 hover:text-foreground">
|
||||
<Trans>Raw JSON</Trans>
|
||||
</summary>
|
||||
<pre className="mt-1 max-h-72 overflow-auto whitespace-pre-wrap break-words rounded border bg-background p-3 font-mono text-[0.7rem] leading-relaxed">
|
||||
{rawPayload}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
@@ -397,7 +427,8 @@ function StarterPromptMarquee({ onSelect }: StarterPromptMarqueeProps) {
|
||||
// unique key. Content-derived keys collide — every `step-start` part serializes identically.
|
||||
const getMessagePartKey = (messageId: string, index: number) => `${messageId}-${index}`;
|
||||
|
||||
export function AssistantMarkdown({ text }: AssistantMarkdownProps) {
|
||||
// Memoized on the text string, so completed markdown stops re-rendering during streaming.
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({ text }: AssistantMarkdownProps) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
skipHtml
|
||||
@@ -437,7 +468,7 @@ export function AssistantMarkdown({ text }: AssistantMarkdownProps) {
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function FileAttachment({ filename, mediaType, state = "done" }: FileAttachmentProps) {
|
||||
return (
|
||||
@@ -456,7 +487,7 @@ function FileAttachment({ filename, mediaType, state = "done" }: FileAttachmentP
|
||||
// The agent asks one question at a time, so this is a single-item Questionnaire: radio choices plus a
|
||||
// freeform answer, submitted as the tool output. `Questionnaire` owns the fieldset/legend semantics,
|
||||
// keyboard shortcuts, focus management, and required-answer validation.
|
||||
export function AskUserQuestion({ part, answer, onAnswer }: AskUserQuestionProps) {
|
||||
export function AskUserQuestion({ part, answer, disabled, onAnswer }: AskUserQuestionProps) {
|
||||
const input =
|
||||
"input" in part && typeof part.input === "object" && part.input ? (part.input as Record<string, unknown>) : {};
|
||||
const choices = Array.isArray(input.choices)
|
||||
@@ -465,7 +496,9 @@ export function AskUserQuestion({ part, answer, onAnswer }: AskUserQuestionProps
|
||||
const question = typeof input.question === "string" ? input.question : t`The agent needs your input.`;
|
||||
const toolCallId = "toolCallId" in part && typeof part.toolCallId === "string" ? part.toolCallId : null;
|
||||
|
||||
if (answer !== null || !toolCallId) {
|
||||
// Read-only threads (archived, missing resume/provider) render the static view: answering would
|
||||
// only mutate local state before the server rejects the continuation.
|
||||
if (answer !== null || !toolCallId || disabled) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-medium">{question}</p>
|
||||
@@ -507,7 +540,16 @@ export function AskUserQuestion({ part, answer, onAnswer }: AskUserQuestionProps
|
||||
);
|
||||
}
|
||||
|
||||
function MessagePart({ part, isUser, onAnswer, onRevert, isReverting, actionsById }: MessagePartProps) {
|
||||
function MessagePart({
|
||||
part,
|
||||
isUser,
|
||||
isReadOnly,
|
||||
onAnswer,
|
||||
onApprovalRespond,
|
||||
onRevert,
|
||||
isReverting,
|
||||
actionsById,
|
||||
}: MessagePartProps) {
|
||||
if (part.type === "text") {
|
||||
return (
|
||||
<Bubble variant={isUser ? "default" : "ghost"} align={isUser ? "end" : "start"}>
|
||||
@@ -543,13 +585,25 @@ function MessagePart({ part, isUser, onAnswer, onRevert, isReverting, actionsByI
|
||||
return (
|
||||
<Bubble variant="outline" className="max-w-full">
|
||||
<BubbleContent className="w-full">
|
||||
<AskUserQuestion part={part} answer={answer} onAnswer={onAnswer} />
|
||||
<AskUserQuestion part={part} answer={answer} disabled={isReadOnly} onAnswer={onAnswer} />
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type === "tool-apply_resume_patch") {
|
||||
const state = "state" in part && typeof part.state === "string" ? part.state : null;
|
||||
|
||||
if (state === "approval-requested" || state === "approval-responded" || state === "output-denied") {
|
||||
return (
|
||||
<Bubble variant="outline" className="max-w-full">
|
||||
<BubbleContent className="w-full">
|
||||
<PatchApprovalCard part={part} disabled={isReadOnly} onRespond={onApprovalRespond} />
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
);
|
||||
}
|
||||
|
||||
const output =
|
||||
"output" in part && typeof part.output === "object" && part.output
|
||||
? (part.output as Record<string, unknown>)
|
||||
@@ -566,55 +620,149 @@ function MessagePart({ part, isUser, onAnswer, onRevert, isReverting, actionsByI
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type === "source-url") {
|
||||
const title = part.title?.trim() || null;
|
||||
if (part.type === "file") {
|
||||
return <FileAttachment filename={part.filename ?? part.url} mediaType={part.mediaType} />;
|
||||
}
|
||||
|
||||
// Previously-invisible tool activity: read_resume, read_attachment, provider-native web_search,
|
||||
// and dynamic tools echoed back after a provider switch.
|
||||
if (
|
||||
part.type === "tool-read_resume" ||
|
||||
part.type === "tool-read_attachment" ||
|
||||
part.type === "tool-web_search" ||
|
||||
part.type === "dynamic-tool"
|
||||
) {
|
||||
return (
|
||||
<Bubble variant="ghost" className="max-w-full">
|
||||
<BubbleContent className="w-full">
|
||||
<a className="block text-primary text-sm underline" href={part.url} target="_blank" rel="noreferrer">
|
||||
{title ? (
|
||||
<>
|
||||
<span className="block truncate">{title}</span>
|
||||
<span className="block truncate text-muted-foreground">{part.url}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="block truncate">{part.url}</span>
|
||||
)}
|
||||
</a>
|
||||
<ToolPartCard part={part} />
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type === "file") {
|
||||
return <FileAttachment filename={part.filename ?? part.url} mediaType={part.mediaType} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ChatMessage({ message, onAnswer, onRevert, isReverting, actionsById }: ChatMessageProps) {
|
||||
type SourceUrlPartLike = { type: "source-url"; url: string; title?: string | null };
|
||||
|
||||
function SourcesBlock({ parts }: { parts: SourceUrlPartLike[] }) {
|
||||
return (
|
||||
<Bubble variant="ghost" className="max-w-full">
|
||||
<BubbleContent className="w-full">
|
||||
<p className="mb-1 font-medium text-muted-foreground text-xs">
|
||||
<Trans>Sources</Trans>
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{parts.map((part, index) => {
|
||||
const title = part.title?.trim() || null;
|
||||
return (
|
||||
// Providers can cite the same URL more than once; the index keeps keys unique.
|
||||
<li key={`${part.url}-${index}`}>
|
||||
<a className="block text-primary text-sm underline" href={part.url} target="_blank" rel="noreferrer">
|
||||
<span className="block truncate">{title ?? part.url}</span>
|
||||
{title ? <span className="block truncate text-muted-foreground">{part.url}</span> : null}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
);
|
||||
}
|
||||
|
||||
type MessageRenderItem =
|
||||
| { kind: "part"; key: string; part: UIMessage["parts"][number] }
|
||||
| { kind: "sources"; key: string; parts: SourceUrlPartLike[] };
|
||||
|
||||
// Consecutive source-url parts collapse into one sources block instead of a bubble per link.
|
||||
function buildRenderItems(message: UIMessage): MessageRenderItem[] {
|
||||
const items: MessageRenderItem[] = [];
|
||||
|
||||
for (const [index, part] of message.parts.entries()) {
|
||||
if (part.type === "source-url") {
|
||||
const last = items.at(-1);
|
||||
if (last?.kind === "sources") {
|
||||
last.parts.push(part as SourceUrlPartLike);
|
||||
continue;
|
||||
}
|
||||
items.push({ kind: "sources", key: getMessagePartKey(message.id, index), parts: [part as SourceUrlPartLike] });
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({ kind: "part", key: getMessagePartKey(message.id, index), part });
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
type MessageUsageMetadata = {
|
||||
model?: string;
|
||||
usage?: { totalTokens?: number; cachedInputTokens?: number };
|
||||
};
|
||||
|
||||
function MessageTokenFooter({ message }: { message: UIMessage }) {
|
||||
// Loose read: legacy rows have no metadata and must keep rendering.
|
||||
const metadata = (message as { metadata?: MessageUsageMetadata }).metadata;
|
||||
const total = metadata?.usage?.totalTokens;
|
||||
if (typeof total !== "number") return null;
|
||||
const cached = metadata?.usage?.cachedInputTokens;
|
||||
|
||||
// Label-form ("Tokens: N") avoids noun declension against the count entirely, so the string
|
||||
// stays grammatical at N=1 in every locale without ICU plural catalogs.
|
||||
return (
|
||||
<p className="px-1 text-[0.7rem] text-muted-foreground/70">
|
||||
{metadata?.model ? `${metadata.model} · ` : null}
|
||||
{typeof cached === "number" && cached > 0 ? (
|
||||
<Trans>
|
||||
Tokens: {total} ({cached} cached)
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>Tokens: {total}</Trans>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// Memoized: completed messages keep stable part references, so they stop re-rendering while a
|
||||
// later message streams (the handlers passed down are useCallback-stable).
|
||||
const ChatMessage = memo(function ChatMessage({
|
||||
message,
|
||||
isReadOnly,
|
||||
onAnswer,
|
||||
onApprovalRespond,
|
||||
onRevert,
|
||||
isReverting,
|
||||
actionsById,
|
||||
}: ChatMessageProps) {
|
||||
const isUser = message.role === "user";
|
||||
|
||||
return (
|
||||
<Message align={isUser ? "end" : "start"}>
|
||||
<MessageContent className={cn(isUser ? "items-end" : "items-start")}>
|
||||
{message.parts.map((part, index) => (
|
||||
<MessagePart
|
||||
key={getMessagePartKey(message.id, index)}
|
||||
part={part}
|
||||
isUser={isUser}
|
||||
onAnswer={onAnswer}
|
||||
onRevert={onRevert}
|
||||
isReverting={isReverting}
|
||||
actionsById={actionsById}
|
||||
/>
|
||||
))}
|
||||
{buildRenderItems(message).map((item) =>
|
||||
item.kind === "sources" ? (
|
||||
<SourcesBlock key={item.key} parts={item.parts} />
|
||||
) : (
|
||||
<MessagePart
|
||||
key={item.key}
|
||||
part={item.part}
|
||||
isUser={isUser}
|
||||
isReadOnly={isReadOnly}
|
||||
onAnswer={onAnswer}
|
||||
onApprovalRespond={onApprovalRespond}
|
||||
onRevert={onRevert}
|
||||
isReverting={isReverting}
|
||||
actionsById={actionsById}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{message.role === "assistant" ? <MessageTokenFooter message={message} /> : null}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function AgentChat({
|
||||
threadId,
|
||||
@@ -622,6 +770,7 @@ export function AgentChat({
|
||||
isReadOnly,
|
||||
readOnlyReason,
|
||||
threadStatus,
|
||||
reviewPatches,
|
||||
activeRunId,
|
||||
actions,
|
||||
onToggleThreads,
|
||||
@@ -642,6 +791,7 @@ export function AgentChat({
|
||||
const revertMutation = useMutation(orpc.agent.actions.revert.mutationOptions());
|
||||
const archiveMutation = useMutation(orpc.agent.threads.archive.mutationOptions());
|
||||
const deleteMutation = useMutation(orpc.agent.threads.delete.mutationOptions());
|
||||
const updateThreadMutation = useMutation(orpc.agent.threads.update.mutationOptions());
|
||||
const isArchived = threadStatus === "archived";
|
||||
|
||||
const refreshThread = useCallback(async () => {
|
||||
@@ -726,12 +876,26 @@ export function AgentChat({
|
||||
[threadId],
|
||||
);
|
||||
|
||||
const { messages, sendMessage, regenerate, setMessages, status, error, clearError, addToolOutput } = useChat({
|
||||
const {
|
||||
messages,
|
||||
sendMessage,
|
||||
regenerate,
|
||||
setMessages,
|
||||
status,
|
||||
error,
|
||||
clearError,
|
||||
addToolOutput,
|
||||
addToolApprovalResponse,
|
||||
} = useChat<AgentUIMessage>({
|
||||
id: threadId,
|
||||
messages: initialMessages,
|
||||
messages: initialMessages as AgentUIMessage[],
|
||||
resume: !!activeRunId,
|
||||
transport,
|
||||
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
|
||||
throttle: 50,
|
||||
messageMetadataSchema: agentMessageMetadataSchema,
|
||||
sendAutomaticallyWhen: (options) =>
|
||||
lastAssistantMessageIsCompleteWithToolCalls(options) ||
|
||||
lastAssistantMessageIsCompleteWithApprovalResponses(options),
|
||||
onFinish: () => {
|
||||
void refreshThread();
|
||||
},
|
||||
@@ -762,11 +926,20 @@ export function AgentChat({
|
||||
useEffect(() => {
|
||||
if (lastSyncedThreadIdRef.current === threadId) return;
|
||||
lastSyncedThreadIdRef.current = threadId;
|
||||
setMessages(initialMessages);
|
||||
setMessages(initialMessages as AgentUIMessage[]);
|
||||
}, [threadId, initialMessages, setMessages]);
|
||||
|
||||
const isStreaming = status === "submitted" || status === "streaming";
|
||||
|
||||
const threadTokenTotal = useMemo(
|
||||
() =>
|
||||
messages.reduce(
|
||||
(sum, message) => sum + ((message as { metadata?: MessageUsageMetadata }).metadata?.usage?.totalTokens ?? 0),
|
||||
0,
|
||||
),
|
||||
[messages],
|
||||
);
|
||||
|
||||
const send = () => {
|
||||
const text = input.trim();
|
||||
if ((!text && pendingAttachments.length === 0) || isReadOnly || isStreaming || isUploading) return;
|
||||
@@ -809,11 +982,7 @@ export function AgentChat({
|
||||
};
|
||||
|
||||
const stopRun = async () => {
|
||||
const last = messages.at(-1);
|
||||
await client.agent.messages.stop({
|
||||
threadId,
|
||||
...(last?.role === "assistant" ? { partialMessage: last } : {}),
|
||||
});
|
||||
await client.agent.messages.stop({ threadId });
|
||||
};
|
||||
|
||||
const copyConversationJson = () => {
|
||||
@@ -840,42 +1009,87 @@ export function AgentChat({
|
||||
toast.add({ type: "success", description: t`Conversation copied.` });
|
||||
};
|
||||
|
||||
const answerToolCall = (toolCallId: string, answer: string) => {
|
||||
addToolOutput({ tool: "ask_user_question", toolCallId, output: answer });
|
||||
};
|
||||
const answerToolCall = useCallback(
|
||||
(toolCallId: string, answer: string) => {
|
||||
addToolOutput({ tool: "ask_user_question", toolCallId, output: answer });
|
||||
},
|
||||
[addToolOutput],
|
||||
);
|
||||
|
||||
const revertAction = (actionId: string) => {
|
||||
const confirmation = window.confirm(
|
||||
t`Restore the resume to before this patch? This will roll back this patch and any patches applied after it.`,
|
||||
);
|
||||
if (!confirmation) return;
|
||||
// Responds locally; the composed sendAutomaticallyWhen resubmits the assistant message once
|
||||
// every pending approval on it has a decision.
|
||||
const respondToApproval = useCallback(
|
||||
(response: PatchApprovalResponse) => {
|
||||
void addToolApprovalResponse(response);
|
||||
},
|
||||
[addToolApprovalResponse],
|
||||
);
|
||||
|
||||
revertMutation.mutate(
|
||||
{ id: actionId },
|
||||
const toggleReviewPatches = (nextReviewPatches: boolean) => {
|
||||
updateThreadMutation.mutate(
|
||||
{ id: threadId, reviewPatches: nextReviewPatches },
|
||||
{
|
||||
onSuccess: (action) => {
|
||||
if (action.status === "conflicted") {
|
||||
toast.add({
|
||||
type: "error",
|
||||
description:
|
||||
action.revertMessage ?? t`Cannot restore; the resume has changed since this edit was applied.`,
|
||||
});
|
||||
} else if (action.status === "rolled_back" || action.status === "reverted") {
|
||||
toast.add({ type: "success", description: t`Patch rolled back.` });
|
||||
}
|
||||
void refreshThread();
|
||||
},
|
||||
onSuccess: () => void refreshThread(),
|
||||
onError: (error) =>
|
||||
toast.add({
|
||||
type: "error",
|
||||
description: getOrpcErrorMessage(error, { fallback: t`Could not restore this patch.` }),
|
||||
description: getOrpcErrorMessage(error, { fallback: t`Failed to update thread settings.` }),
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const revertActionMutate = revertMutation.mutate;
|
||||
const revertAction = useCallback(
|
||||
(actionId: string) => {
|
||||
void (async () => {
|
||||
const confirmation = await confirm(t`Restore the resume to before this patch?`, {
|
||||
description: t`This will roll back this patch and any patches applied after it.`,
|
||||
});
|
||||
if (!confirmation) return;
|
||||
|
||||
revertActionMutate(
|
||||
{ id: actionId },
|
||||
{
|
||||
onSuccess: (action) => {
|
||||
if (action.status === "conflicted") {
|
||||
toast.add({
|
||||
type: "error",
|
||||
description:
|
||||
action.revertMessage ?? t`Cannot restore; the resume has changed since this edit was applied.`,
|
||||
});
|
||||
} else if (action.status === "rolled_back" || action.status === "reverted") {
|
||||
toast.add({ type: "success", description: t`Patch rolled back.` });
|
||||
}
|
||||
void refreshThread();
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.add({
|
||||
type: "error",
|
||||
description: getOrpcErrorMessage(error, { fallback: t`Could not restore this patch.` }),
|
||||
}),
|
||||
},
|
||||
);
|
||||
})();
|
||||
},
|
||||
[confirm, revertActionMutate, refreshThread],
|
||||
);
|
||||
|
||||
const retryLastMessage = () => {
|
||||
clearError();
|
||||
// A failed question/approval continuation must not regenerate: regenerate() removes the
|
||||
// answered assistant message and resends the prior user prompt, losing the recorded
|
||||
// decision. Resubmitting the transcript (sendMessage with no message) retries the
|
||||
// continuation itself; regenerate stays for ordinary generation failures.
|
||||
const last = messages.at(-1);
|
||||
if (
|
||||
last?.role === "assistant" &&
|
||||
(lastAssistantMessageIsCompleteWithToolCalls({ messages }) ||
|
||||
lastAssistantMessageIsCompleteWithApprovalResponses({ messages }))
|
||||
) {
|
||||
void sendMessage();
|
||||
return;
|
||||
}
|
||||
void regenerate();
|
||||
};
|
||||
|
||||
@@ -885,12 +1099,17 @@ export function AgentChat({
|
||||
isArchived={isArchived}
|
||||
isArchivePending={archiveMutation.isPending}
|
||||
isDeletePending={deleteMutation.isPending}
|
||||
isUpdatePending={updateThreadMutation.isPending}
|
||||
isStreaming={isStreaming}
|
||||
reviewPatches={reviewPatches}
|
||||
threadTokenTotal={threadTokenTotal}
|
||||
onArchive={handleArchive}
|
||||
onClose={onClose}
|
||||
onCopyConversation={copyConversationText}
|
||||
onCopyConversationJson={copyConversationJson}
|
||||
onDelete={() => void handleDelete()}
|
||||
onToggleResume={onToggleResume}
|
||||
onToggleReviewPatches={toggleReviewPatches}
|
||||
onToggleThreads={onToggleThreads}
|
||||
/>
|
||||
|
||||
@@ -904,6 +1123,7 @@ export function AgentChat({
|
||||
isStreaming={isStreaming}
|
||||
messages={messages}
|
||||
onAnswer={answerToolCall}
|
||||
onApprovalRespond={respondToApproval}
|
||||
onRevert={revertAction}
|
||||
onRetry={retryLastMessage}
|
||||
onStarterSelect={setInput}
|
||||
@@ -947,6 +1167,7 @@ function AgentChatMessages({
|
||||
isStreaming,
|
||||
messages,
|
||||
onAnswer,
|
||||
onApprovalRespond,
|
||||
onRevert,
|
||||
onRetry,
|
||||
onStarterSelect,
|
||||
@@ -976,9 +1197,11 @@ function AgentChatMessages({
|
||||
<MessageScrollerItem key={message.id} messageId={message.id} scrollAnchor={message.role === "user"}>
|
||||
<ChatMessage
|
||||
message={message}
|
||||
isReadOnly={isReadOnly}
|
||||
isReverting={isReverting}
|
||||
actionsById={actionsById}
|
||||
onAnswer={onAnswer}
|
||||
onApprovalRespond={onApprovalRespond}
|
||||
onRevert={onRevert}
|
||||
/>
|
||||
</MessageScrollerItem>
|
||||
@@ -1022,12 +1245,17 @@ function AgentChatHeader({
|
||||
isArchived,
|
||||
isArchivePending,
|
||||
isDeletePending,
|
||||
isUpdatePending,
|
||||
isStreaming,
|
||||
reviewPatches,
|
||||
threadTokenTotal,
|
||||
onArchive,
|
||||
onClose,
|
||||
onCopyConversation,
|
||||
onCopyConversationJson,
|
||||
onDelete,
|
||||
onToggleResume,
|
||||
onToggleReviewPatches,
|
||||
onToggleThreads,
|
||||
}: AgentChatHeaderProps) {
|
||||
return (
|
||||
@@ -1045,6 +1273,11 @@ function AgentChatHeader({
|
||||
<div className="min-w-0 truncate font-semibold">
|
||||
<Trans>Chat</Trans>
|
||||
</div>
|
||||
{threadTokenTotal > 0 ? (
|
||||
<span className="shrink-0 text-muted-foreground/70 text-xs">
|
||||
<Trans>Tokens: {threadTokenTotal}</Trans>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{onToggleResume ? (
|
||||
@@ -1079,6 +1312,18 @@ function AgentChatHeader({
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{!isArchived ? (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={reviewPatches}
|
||||
// Approval behavior is captured when a run starts; toggling mid-run would
|
||||
// misrepresent what the streaming run actually does (server rejects it too).
|
||||
disabled={isUpdatePending || isStreaming}
|
||||
onCheckedChange={(checked) => onToggleReviewPatches(checked === true)}
|
||||
>
|
||||
<Trans>Review edits</Trans>
|
||||
</DropdownMenuCheckboxItem>
|
||||
) : null}
|
||||
|
||||
{!isArchived ? (
|
||||
<DropdownMenuItem disabled={isArchivePending} onClick={onArchive}>
|
||||
<ArchiveIcon />
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { UIMessage } from "ai";
|
||||
import type { PatchApprovalResponse } from "./patch-approval-card";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { PatchApprovalCard } from "./patch-approval-card";
|
||||
|
||||
function approvalPart(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: "tool-apply_resume_patch",
|
||||
toolCallId: "call-1",
|
||||
state: "approval-requested",
|
||||
input: {
|
||||
title: "Tighten summary",
|
||||
summary: "Rewrites the opening line",
|
||||
operations: [{ op: "replace", path: "/sections/summary/content", value: "Impact-driven engineer" }],
|
||||
},
|
||||
approval: { id: "approval-1", signature: "sig" },
|
||||
...overrides,
|
||||
} as unknown as UIMessage["parts"][number];
|
||||
}
|
||||
|
||||
const renderCard = (part: UIMessage["parts"][number], onRespond: (response: PatchApprovalResponse) => void) =>
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<PatchApprovalCard part={part} onRespond={onRespond} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe("PatchApprovalCard", () => {
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
it("renders the request with operation rows and calls back on approve", () => {
|
||||
const onRespond = vi.fn();
|
||||
renderCard(approvalPart(), onRespond);
|
||||
|
||||
expect(screen.getByText("Tighten summary")).toBeInTheDocument();
|
||||
expect(screen.getByText("/sections/summary/content")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
|
||||
|
||||
expect(onRespond).toHaveBeenCalledWith({ id: "approval-1", approved: true });
|
||||
});
|
||||
|
||||
it("calls back on deny with the optional reason", () => {
|
||||
const onRespond = vi.fn();
|
||||
renderCard(approvalPart(), onRespond);
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Optional note for the agent" }), {
|
||||
target: { value: "Wrong section" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Deny" }));
|
||||
|
||||
expect(onRespond).toHaveBeenCalledWith({ id: "approval-1", approved: false, reason: "Wrong section" });
|
||||
});
|
||||
|
||||
it("renders no response controls when disabled (read-only thread)", () => {
|
||||
const onRespond = vi.fn();
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<PatchApprovalCard part={approvalPart()} disabled onRespond={onRespond} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("This edit request can no longer be answered.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the buttons once the approval has been responded to", () => {
|
||||
const onRespond = vi.fn();
|
||||
renderCard(
|
||||
approvalPart({ state: "approval-responded", approval: { id: "approval-1", approved: true } }),
|
||||
onRespond,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Approved — waiting for the agent…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the declined terminal state", () => {
|
||||
const onRespond = vi.fn();
|
||||
renderCard(
|
||||
approvalPart({ state: "output-denied", approval: { id: "approval-1", approved: false, reason: "No" } }),
|
||||
onRespond,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Edit declined/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CheckIcon, ProhibitIcon, ShieldCheckIcon } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Textarea } from "@reactive-resume/ui/components/textarea";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
export type PatchApprovalResponse = {
|
||||
id: string;
|
||||
approved: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type PatchApprovalCardProps = {
|
||||
part: UIMessage["parts"][number];
|
||||
disabled?: boolean;
|
||||
onRespond: (response: PatchApprovalResponse) => void;
|
||||
};
|
||||
|
||||
type ApprovalPartFields = {
|
||||
state?: string;
|
||||
input?: unknown;
|
||||
approval?: { id?: string; approved?: boolean; reason?: string };
|
||||
};
|
||||
|
||||
export type PatchOperationLike = { op?: unknown; path?: unknown; value?: unknown; from?: unknown };
|
||||
|
||||
function truncateValue(value: unknown, max = 80) {
|
||||
if (value === undefined) return null;
|
||||
const text = typeof value === "string" ? value : JSON.stringify(value);
|
||||
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
||||
}
|
||||
|
||||
export function OperationRow({ operation }: { operation: PatchOperationLike }) {
|
||||
const valuePreview = truncateValue(operation.value);
|
||||
|
||||
return (
|
||||
<li className="flex min-w-0 items-baseline gap-2 font-mono text-[0.7rem] leading-relaxed">
|
||||
<Badge variant="outline" className="shrink-0 font-mono uppercase">
|
||||
{String(operation.op ?? "?")}
|
||||
</Badge>
|
||||
<span className="shrink-0 text-foreground">{String(operation.path ?? "")}</span>
|
||||
{typeof operation.from === "string" ? (
|
||||
<span className="truncate text-muted-foreground">
|
||||
{/* One translatable phrase: several languages place the source marker after the path. */}
|
||||
<Trans>Source: {operation.from}</Trans>
|
||||
</span>
|
||||
) : null}
|
||||
{valuePreview ? <span className="truncate text-muted-foreground">{valuePreview}</span> : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// Renders the approval lifecycle of an apply_resume_patch call: a pending request with
|
||||
// Approve/Deny, the waiting state after a response, and the declined terminal state.
|
||||
export function PatchApprovalCard({ part, disabled, onRespond }: PatchApprovalCardProps) {
|
||||
const [reason, setReason] = useState("");
|
||||
const fields = part as ApprovalPartFields;
|
||||
const input = (typeof fields.input === "object" && fields.input ? fields.input : {}) as Record<string, unknown>;
|
||||
const title = typeof input.title === "string" ? input.title : t`Resume edit`;
|
||||
const summary = typeof input.summary === "string" ? input.summary : null;
|
||||
const operations = Array.isArray(input.operations) ? (input.operations as PatchOperationLike[]) : [];
|
||||
const approvalId = typeof fields.approval?.id === "string" ? fields.approval.id : null;
|
||||
const state = fields.state;
|
||||
|
||||
if (state === "output-denied") {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<ProhibitIcon />
|
||||
<span>
|
||||
<Trans>Edit declined</Trans>
|
||||
{fields.approval?.reason ? ` — ${fields.approval.reason}` : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Read-only threads keep the request visible but never actionable: responding would only
|
||||
// change local state before the server rejects the continuation.
|
||||
const isPending = state === "approval-requested" && approvalId !== null && !disabled;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<ShieldCheckIcon className="text-muted-foreground" />
|
||||
<span>
|
||||
<Trans>Review this edit</Trans>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{title}</p>
|
||||
{summary ? <p className="mt-0.5 text-muted-foreground text-xs">{summary}</p> : null}
|
||||
</div>
|
||||
|
||||
{operations.length > 0 ? (
|
||||
<ul className="max-h-48 space-y-1 overflow-auto rounded-md border bg-muted/20 p-2">
|
||||
{operations.map((operation, index) => (
|
||||
<OperationRow key={`${String(operation.path)}-${index}`} operation={operation} />
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{isPending ? (
|
||||
<>
|
||||
<Textarea
|
||||
rows={1}
|
||||
value={reason}
|
||||
aria-label={t`Optional note for the agent`}
|
||||
placeholder={t`Optional note for the agent…`}
|
||||
className="min-h-8 text-xs"
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onRespond({ id: approvalId, approved: true, ...(reason.trim() ? { reason: reason.trim() } : {}) })
|
||||
}
|
||||
>
|
||||
<CheckIcon />
|
||||
<Trans>Approve</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onRespond({ id: approvalId, approved: false, ...(reason.trim() ? { reason: reason.trim() } : {}) })
|
||||
}
|
||||
>
|
||||
<ProhibitIcon />
|
||||
<Trans>Deny</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className={cn("text-muted-foreground text-xs")}>
|
||||
{fields.approval?.approved === false ? (
|
||||
<Trans>Denied — waiting for the agent…</Trans>
|
||||
) : fields.approval?.approved === true ? (
|
||||
<Trans>Approved — waiting for the agent…</Trans>
|
||||
) : (
|
||||
<Trans>This edit request can no longer be answered.</Trans>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { FileTextIcon, GlobeIcon, ReadCvLogoIcon, WrenchIcon } from "@phosphor-icons/react";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
|
||||
export type ToolPartCardProps = {
|
||||
part: UIMessage["parts"][number];
|
||||
};
|
||||
|
||||
type ToolPartFields = {
|
||||
type: string;
|
||||
toolName?: string;
|
||||
state?: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
};
|
||||
|
||||
function toolDisplay(part: ToolPartFields): { label: string; icon: React.ReactNode } {
|
||||
switch (part.type) {
|
||||
case "tool-read_resume": {
|
||||
return { label: t`Read the resume`, icon: <ReadCvLogoIcon /> };
|
||||
}
|
||||
case "tool-read_attachment": {
|
||||
return { label: t`Read an attachment`, icon: <FileTextIcon /> };
|
||||
}
|
||||
case "tool-web_search": {
|
||||
// State-neutral: this label sits next to a live Running…/Done/Failed badge.
|
||||
return { label: t`Web search`, icon: <GlobeIcon /> };
|
||||
}
|
||||
default: {
|
||||
return { label: part.toolName ?? part.type, icon: <WrenchIcon /> };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stateBadge(state: string | undefined, errorText: string | undefined) {
|
||||
if (state === "output-error" || errorText) return { label: t`Failed`, variant: "destructive" as const };
|
||||
if (state === "output-available") return { label: t`Done`, variant: "outline" as const };
|
||||
return { label: t`Running…`, variant: "secondary" as const };
|
||||
}
|
||||
|
||||
function payloadPreview(value: unknown) {
|
||||
if (value === undefined || value === null) return null;
|
||||
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
||||
return text.length > 4_000 ? `${text.slice(0, 4_000)}…` : text;
|
||||
}
|
||||
|
||||
// Generic collapsed card for tool activity that previously rendered as nothing:
|
||||
// read_resume / read_attachment / web_search plus the dynamic-tool fallback.
|
||||
export function ToolPartCard({ part }: ToolPartCardProps) {
|
||||
const fields = part as ToolPartFields;
|
||||
const { label, icon } = toolDisplay(fields);
|
||||
const badge = stateBadge(fields.state, fields.errorText);
|
||||
const inputPreview = payloadPreview(fields.input);
|
||||
const outputPreview = payloadPreview(fields.output);
|
||||
|
||||
return (
|
||||
<details className="group text-muted-foreground text-xs">
|
||||
<summary className="inline-flex cursor-pointer list-none items-center gap-2 rounded-md py-1 font-medium hover:text-foreground [&::-webkit-details-marker]:hidden">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
<Badge variant={badge.variant}>{badge.label}</Badge>
|
||||
</summary>
|
||||
|
||||
<div className="mt-2 space-y-2 rounded-md border bg-muted/20 p-3">
|
||||
{fields.errorText ? <p className="text-rose-500">{fields.errorText}</p> : null}
|
||||
{inputPreview && inputPreview !== "{}" ? (
|
||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words rounded border bg-background p-2 font-mono text-[0.7rem]">
|
||||
{inputPreview}
|
||||
</pre>
|
||||
) : null}
|
||||
{outputPreview ? (
|
||||
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-words rounded border bg-background p-2 font-mono text-[0.7rem]">
|
||||
{outputPreview}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user