GenAI Chat Improvements (#24173)

* Initial tool approval implementation

* Cleanups and fixes

* Improve robustness of loading case
This commit is contained in:
Nicolas Mowen
2026-09-12 07:30:04 -06:00
parent 76708e7fa6
commit af60d2db48
14 changed files with 1506 additions and 207 deletions
+10 -4
View File
@@ -26,6 +26,9 @@ type ChatComposerProps = {
isLoading?: boolean;
onStop?: () => void;
/** Blocks input without showing the stop button, e.g. while a tool call
* is waiting for the user's approval. */
disabled?: boolean;
attachedEventId?: string | null;
onClearAttachment?: () => void;
@@ -45,6 +48,7 @@ export function ChatComposer({
setThinkingEnabled,
isLoading = false,
onStop,
disabled = false,
attachedEventId,
onClearAttachment,
onAttach,
@@ -62,6 +66,7 @@ export function ChatComposer({
const showPaperclip = !!onAttach;
const showStop = isLoading && !!onStop;
const inputBlocked = isLoading || disabled;
return (
<div className="flex w-full flex-col items-stretch justify-center gap-2 rounded-xl bg-secondary p-3">
@@ -77,7 +82,7 @@ export function ChatComposer({
{attachedEventId && (
<ChatQuickReplies
onSend={(text) => sendMessage(text)}
disabled={isLoading}
disabled={inputBlocked}
/>
)}
<div className="flex w-full flex-row items-center gap-2">
@@ -85,7 +90,7 @@ export function ChatComposer({
<ChatPaperclipButton
recentEventIds={recentEventIds ?? []}
onAttach={onAttach!}
disabled={isLoading || attachedEventId != null}
disabled={inputBlocked || attachedEventId != null}
/>
)}
{supportsThinking && (
@@ -103,7 +108,7 @@ export function ChatComposer({
!thinkingEnabled && "text-secondary-foreground",
)}
onClick={() => setThinkingEnabled(!thinkingEnabled)}
disabled={isLoading}
disabled={inputBlocked}
>
<LuBrain className="size-4" />
</Button>
@@ -122,6 +127,7 @@ export function ChatComposer({
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
aria-busy={isLoading}
disabled={disabled}
/>
{showStop ? (
<Button
@@ -135,7 +141,7 @@ export function ChatComposer({
<Button
variant="select"
className="size-10 shrink-0 rounded-full"
disabled={!input.trim() || isLoading}
disabled={!input.trim() || inputBlocked}
onClick={() => sendMessage()}
>
<FaArrowUpLong className="size-4" />
+39
View File
@@ -16,12 +16,15 @@ import { Label } from "@/components/ui/label";
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { useTranslation } from "react-i18next";
import type { ShowStatsMode } from "@/types/chat";
import { formatToolName } from "@/utils/chatUtil";
type ChatSettingsProps = {
showStats: ShowStatsMode;
setShowStats: (mode: ShowStatsMode) => void;
autoScroll: boolean;
setAutoScroll: (enabled: boolean) => void;
alwaysAllowTools: string[];
clearAlwaysAllowTools: () => void;
};
export default function ChatSettings({
@@ -29,6 +32,8 @@ export default function ChatSettings({
setShowStats,
autoScroll,
setAutoScroll,
alwaysAllowTools,
clearAlwaysAllowTools,
}: ChatSettingsProps) {
const { t } = useTranslation(["views/chat"]);
const [open, setOpen] = useState(false);
@@ -90,6 +95,40 @@ export default function ChatSettings({
onCheckedChange={setAutoScroll}
/>
</div>
<DropdownMenuSeparator />
<div className="space-y-3">
<div className="space-y-0.5">
<div>{t("settings.always_allow.title")}</div>
<div className="text-xs text-muted-foreground">
{t("settings.always_allow.desc")}
</div>
</div>
{alwaysAllowTools.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{alwaysAllowTools.map((name) => (
<span
key={name}
className="rounded-md bg-secondary px-2 py-0.5 text-xs text-secondary-foreground"
>
{formatToolName(name)}
</span>
))}
</div>
) : (
<div className="text-xs text-muted-foreground">
{t("settings.always_allow.none")}
</div>
)}
<Button
size="sm"
variant="outline"
className="w-full"
disabled={alwaysAllowTools.length === 0}
onClick={clearAlwaysAllowTools}
>
{t("settings.always_allow.reset")}
</Button>
</div>
</div>
);
@@ -0,0 +1,97 @@
import { useTranslation } from "react-i18next";
import { LuShieldAlert, LuCheck, LuX } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { formatToolName } from "@/utils/chatUtil";
import type { PendingToolCall, ToolDecision } from "@/types/chat";
type ToolApprovalCardProps = {
toolCall: PendingToolCall;
decision?: ToolDecision;
onApprove: (id: string) => void;
onAlwaysAllow: (id: string, name: string) => void;
onReject: (id: string) => void;
};
/**
* Prompt shown when the assistant wants to run a state-changing tool.
* Renders the call's arguments and approve / always allow / reject actions;
* once decided it collapses into a status line.
*/
export function ToolApprovalCard({
toolCall,
decision,
onApprove,
onAlwaysAllow,
onReject,
}: ToolApprovalCardProps) {
const { t } = useTranslation(["views/chat"]);
const displayName = formatToolName(toolCall.name);
const hasArguments = Object.keys(toolCall.arguments ?? {}).length > 0;
return (
<div
className="flex w-full max-w-[85%] flex-col gap-3 self-start rounded-xl border border-border bg-muted px-4 py-3"
role="group"
aria-label={t("approval.title", { tool: displayName })}
>
<div className="flex items-start gap-2">
<LuShieldAlert className="mt-0.5 size-4 shrink-0 text-primary" />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium">
{t("approval.title", { tool: displayName })}
</span>
<span className="text-xs text-muted-foreground">
{t("approval.desc")}
</span>
</div>
</div>
{hasArguments && (
<pre className="scrollbar-container max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-background/50 p-2 text-[10px]">
{JSON.stringify(toolCall.arguments, null, 2)}
</pre>
)}
{decision ? (
<div
className={cn(
"flex items-center gap-1.5 text-xs font-medium",
decision === "approve" ? "text-success" : "text-destructive",
)}
>
{decision === "approve" ? (
<LuCheck className="size-3.5" />
) : (
<LuX className="size-3.5" />
)}
{decision === "approve"
? t("approval.approved")
: t("approval.rejected")}
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="secondary"
onClick={() => onApprove(toolCall.id)}
>
{t("approval.approve")}
</Button>
<Button
size="sm"
variant="select"
onClick={() => onAlwaysAllow(toolCall.id, toolCall.name)}
>
{t("approval.always_allow")}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => onReject(toolCall.id)}
>
{t("approval.reject")}
</Button>
</div>
)}
</div>
);
}
+2 -9
View File
@@ -7,19 +7,12 @@ import {
} from "@/components/ui/collapsible";
import { LuChevronsUpDown } from "react-icons/lu";
import type { ToolCall } from "@/types/chat";
import { formatToolName } from "@/utils/chatUtil";
type ToolCallsGroupProps = {
toolCalls: ToolCall[];
};
function normalizeName(name: string): string {
return name
.replace(/_/g, " ")
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
export function ToolCallsGroup({ toolCalls }: ToolCallsGroupProps) {
const grouped = useMemo(() => {
const map = new Map<string, ToolCall[]>();
@@ -53,7 +46,7 @@ type ToolCallRowProps = {
function ToolCallRow({ name, calls }: ToolCallRowProps) {
const { t } = useTranslation(["views/chat"]);
const [open, setOpen] = useState(false);
const displayName = normalizeName(name);
const displayName = formatToolName(name);
const label =
calls.length > 1 ? `${displayName} (\u00d7${calls.length})` : displayName;