Skip to content
This repository was archived by the owner on Sep 23, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions web/src/components/ai-elements/subagent-steps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export type SubagentActivityProps = ComponentProps<"div"> & {
steps: SubagentStep[];
isRunning?: boolean;
defaultOpen?: boolean;
/** Built-in subagent type (coder / explore / plan) */
subagentType?: string;
};

export const SubagentActivity = memo(
Expand All @@ -33,8 +35,12 @@ export const SubagentActivity = memo(
steps,
isRunning = false,
defaultOpen = false,
subagentType,
...props
}: SubagentActivityProps) => {
const agentLabel = subagentType
? `${subagentType.charAt(0).toUpperCase() + subagentType.slice(1)} agent`
: "Agent";
const [isOpen, setIsOpen] = useState(defaultOpen);

const toolCallCount = steps.filter((s) => s.kind === "tool-call").length;
Expand Down Expand Up @@ -63,7 +69,7 @@ export const SubagentActivity = memo(
<span>
{isRunning ? (
<>
Agent working
{agentLabel} working
<Shimmer
as="span"
duration={1}
Expand All @@ -73,9 +79,9 @@ export const SubagentActivity = memo(
</Shimmer>
</>
) : toolCallCount > 0 ? (
`Agent completed · ${toolCallCount} tool call${toolCallCount !== 1 ? "s" : ""}`
`${agentLabel} completed · ${toolCallCount} tool call${toolCallCount !== 1 ? "s" : ""}`
) : (
"Agent completed"
`${agentLabel} completed`
)}
</span>
<ChevronRightIcon
Expand Down
8 changes: 4 additions & 4 deletions web/src/features/chat/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export const ChatWorkspace = memo(function ChatWorkspaceComponent({
const isUploading = isUploadingFiles;

const handleApprovalAction = useCallback(
async (approval: ToolApproval, decision: ApprovalResponseDecision) => {
async (approval: ToolApproval, decision: ApprovalResponseDecision, reason?: string) => {
if (!(approval?.id && onApprovalResponse)) {
return;
}
Expand All @@ -185,7 +185,7 @@ export const ChatWorkspace = memo(function ChatWorkspaceComponent({
}));

try {
await onApprovalResponse(approval.id, decision);
await onApprovalResponse(approval.id, decision, reason);
} catch (error) {
console.error("[ChatWorkspace] Failed to respond to approval", error);
toast.error("Approval action failed", {
Expand All @@ -205,13 +205,13 @@ export const ChatWorkspace = memo(function ChatWorkspaceComponent({
// Wrapper for ApprovalDialog that routes through handleApprovalAction
// so pendingApprovalMap is properly managed (prevents duplicate requests)
const handleDialogApprovalResponse = useCallback(
async (requestId: string, decision: ApprovalResponseDecision) => {
async (requestId: string, decision: ApprovalResponseDecision, reason?: string) => {
for (const message of messages) {
if (
message.variant === "tool" &&
message.toolCall?.approval?.id === requestId
) {
await handleApprovalAction(message.toolCall.approval, decision);
await handleApprovalAction(message.toolCall.approval, decision, reason);
return;
}
}
Expand Down
202 changes: 167 additions & 35 deletions web/src/features/chat/components/approval-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import { cn } from "@/lib/utils";
Expand All @@ -22,6 +22,10 @@ export function ApprovalDialog({
pendingApprovalMap,
canRespondToApproval,
}: ApprovalDialogProps) {
const [feedbackMode, setFeedbackMode] = useState(false);
const [feedbackText, setFeedbackText] = useState("");
const feedbackInputRef = useRef<HTMLTextAreaElement>(null);

// from messages, extract the pending approval request
const pendingApproval = useMemo(() => {
for (const message of messages) {
Expand All @@ -41,22 +45,43 @@ export function ApprovalDialog({
return null;
}, [messages]);

// Reset feedback state when the pending approval changes
const currentApprovalId = pendingApproval?.approval?.id;
const prevApprovalIdRef = useRef(currentApprovalId);
if (prevApprovalIdRef.current !== currentApprovalId) {
prevApprovalIdRef.current = currentApprovalId;
// Always clear stale feedback text, not just when feedbackMode is active.
// Otherwise old text leaks into the next approval's feedback input.
if (feedbackMode || feedbackText) {
setFeedbackMode(false);
setFeedbackText("");
}
}
Comment on lines +51 to +59

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

State is being updated during render (if (prevApprovalIdRef.current !== currentApprovalId) { ... setFeedbackMode/setFeedbackText ... }). Updating state while rendering can trigger React warnings and render loops (especially under StrictMode). Move this reset logic into a useEffect that runs when currentApprovalId changes (and clear feedback state there).

Suggested change
if (prevApprovalIdRef.current !== currentApprovalId) {
prevApprovalIdRef.current = currentApprovalId;
// Always clear stale feedback text, not just when feedbackMode is active.
// Otherwise old text leaks into the next approval's feedback input.
if (feedbackMode || feedbackText) {
setFeedbackMode(false);
setFeedbackText("");
}
}
useEffect(() => {
if (prevApprovalIdRef.current !== currentApprovalId) {
prevApprovalIdRef.current = currentApprovalId;
// Always clear stale feedback text, not just when feedbackMode is active.
// Otherwise old text leaks into the next approval's feedback input.
setFeedbackMode(false);
setFeedbackText("");
}
}, [currentApprovalId]);

Copilot uses AI. Check for mistakes.

const handleResponse = useCallback(
async (decision: ApprovalResponseDecision) => {
async (decision: ApprovalResponseDecision, reason?: string) => {
if (!(pendingApproval && onApprovalResponse)) return;

const { approval } = pendingApproval;
if (!approval.id) return;

try {
await onApprovalResponse(approval.id, decision);
await onApprovalResponse(approval.id, decision, reason);
} catch (error) {
console.error("[ApprovalDialog] Failed to respond", error);
}
},
[pendingApproval, onApprovalResponse],
);

const handleFeedbackSubmit = useCallback(() => {
const trimmed = feedbackText.trim();
if (!trimmed) return;
setFeedbackMode(false);
setFeedbackText("");
handleResponse("reject", trimmed);
}, [feedbackText, handleResponse]);

// Compute disable state before early return (hooks must run unconditionally)
const approvalId = pendingApproval?.approval?.id;
const approvalPending = approvalId
Expand All @@ -65,9 +90,21 @@ export function ApprovalDialog({
const disableActions =
!(canRespondToApproval && onApprovalResponse) || approvalPending;

// Keyboard shortcuts: 1=Approve, 2=Approve for session, 3=Decline
// Focus the feedback input when feedback mode is activated
useEffect(() => {
if (feedbackMode) {
// Use rAF to wait for the DOM to be ready after state update
requestAnimationFrame(() => {
feedbackInputRef.current?.focus();
});
}
}, [feedbackMode]);

// Keyboard shortcuts: 1=Approve, 2=Approve for session, 3=Decline, 4=Decline with feedback
useEffect(() => {
if (!pendingApproval || disableActions) return;
// When in feedback mode, don't handle number shortcuts
if (feedbackMode) return;

const handleKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
Expand All @@ -83,6 +120,12 @@ export function ApprovalDialog({
if ((el as HTMLElement).isContentEditable) return;
}

if (event.key === "4") {
event.preventDefault();
setFeedbackMode(true);
return;
}

const keyMap: Record<string, ApprovalResponseDecision> = {
"1": "approve",
"2": "approve_for_session",
Expand All @@ -97,23 +140,31 @@ export function ApprovalDialog({

window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [pendingApproval, disableActions, handleResponse]);
}, [pendingApproval, disableActions, handleResponse, feedbackMode]);

// if no pending approval request, do not render anything
if (!pendingApproval) return null;

const { approval, toolCall } = pendingApproval;

const options = [
{ key: "approve", label: "Approve", pendingLabel: "Approving...", index: 1 },
{
key: "approve_for_session",
label: "Approve for session",
pendingLabel: "Approving...",
index: 2,
},
{ key: "reject", label: "Decline", pendingLabel: "Declining...", index: 3 },
] as const;
const sourceLabel = (() => {
if (approval.sourceDescription) return approval.sourceDescription;
const agentType = toolCall.subagentType;
const agentId = toolCall.subagentAgentId;
const idSuffix = agentId ? ` (${agentId})` : "";
if (approval.sourceKind === "background_agent") {
return agentType
? `Background · ${agentType}${idSuffix}`
: `Background agent${idSuffix}`;
}
// Foreground sub-agent approvals (isSubagentOrigin)
if (toolCall.isSubagentOrigin) {
return agentType
? `${agentType}${idSuffix}`
: `Sub-agent${idSuffix}`;
}
return null;
})();

return (
<div className="px-3 pb-2 w-full">
Expand All @@ -140,6 +191,11 @@ export function ApprovalDialog({
· {approval.sender}
</span>
)}
{sourceLabel && (
<span className="text-xs text-muted-foreground/70 bg-muted/50 px-1.5 py-0.5 rounded">
{sourceLabel}
</span>
)}
</div>

{/* Description */}
Expand Down Expand Up @@ -182,28 +238,104 @@ export function ApprovalDialog({

{/* Action buttons */}
<div className="flex flex-wrap items-center gap-2">
{options.map((option) => (
<Button
key={option.key}
size="sm"
variant={option.key === "reject" ? "ghost" : "outline"}
disabled={disableActions}
onClick={() => handleResponse(option.key)}
<Button
size="sm"
variant="outline"
disabled={disableActions}
onClick={() => handleResponse("approve")}
className="transition-all"
>
{approvalPending ? "Approving..." : "Approve"}
{!approvalPending && <Kbd className="ml-1.5">1</Kbd>}
</Button>
<Button
size="sm"
variant="outline"
disabled={disableActions}
onClick={() => handleResponse("approve_for_session")}
className="transition-all"
>
{approvalPending ? "Approving..." : "Approve for session"}
{!approvalPending && <Kbd className="ml-1.5">2</Kbd>}
</Button>
<Button
size="sm"
variant="ghost"
disabled={disableActions}
onClick={() => handleResponse("reject")}
className={cn(
"transition-all",
"text-muted-foreground hover:text-destructive hover:bg-destructive/10",
)}
>
{approvalPending ? "Declining..." : "Decline"}
{!approvalPending && <Kbd className="ml-1.5">3</Kbd>}
</Button>
<Button
size="sm"
variant="ghost"
disabled={disableActions}
onClick={() => setFeedbackMode(!feedbackMode)}
className={cn(
"transition-all",
feedbackMode
? "text-foreground bg-muted"
: "text-muted-foreground hover:text-foreground",
)}
>
{feedbackMode ? "Cancel feedback" : "Decline with feedback"}
{!feedbackMode && !approvalPending && (
<Kbd className="ml-1.5">4</Kbd>
)}
</Button>
</div>

{/* Feedback input */}
{feedbackMode && (
<div className="flex flex-col gap-1.5">
<textarea
ref={feedbackInputRef}
value={feedbackText}
onChange={(e) => setFeedbackText(e.target.value)}
onKeyDown={(e) => {
// Guard against IME composition (e.g. Chinese input)
if (e.nativeEvent.isComposing) return;
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleFeedbackSubmit();
}
if (e.key === "Escape") {
e.preventDefault();
setFeedbackMode(false);
setFeedbackText("");
}
}}
placeholder="Tell the model what to do instead..."
className={cn(
"transition-all",
option.key === "reject" &&
"text-muted-foreground hover:text-destructive hover:bg-destructive/10",
)}
>
{approvalPending
? option.pendingLabel
: option.label}
{!approvalPending && (
<Kbd className="ml-1.5">{option.index}</Kbd>
"w-full rounded-md border border-border/60 bg-muted/30",
"px-3 py-2 text-sm text-foreground",
"placeholder:text-muted-foreground/50",
"focus:outline-none focus:ring-1 focus:ring-ring",
"resize-none",
)}
</Button>
))}
</div>
rows={2}
/>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
Enter to submit · Shift+Enter for newline · Esc to cancel
</span>
<Button
size="sm"
variant="outline"
disabled={!feedbackText.trim()}
onClick={handleFeedbackSubmit}
className="text-xs"
>
Submit feedback
</Button>
</div>
</div>
)}
</div>
</div>
</div>
Expand Down
23 changes: 22 additions & 1 deletion web/src/features/chat/components/assistant-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,13 @@ const renderToolMessage = ({
isApprovalRequested
);

return (
const subagentOriginLabel = toolCall.isSubagentOrigin
? toolCall.subagentType
? `${toolCall.subagentType} agent`
: "sub-agent"
: null;

const toolBlock = (
<>
<Tool
key={`${message.id}-${blocksExpanded}`}
Expand All @@ -225,6 +231,7 @@ const renderToolMessage = ({
steps={toolCall.subagentSteps}
isRunning={toolCall.subagentRunning}
defaultOpen={blocksExpanded}
subagentType={toolCall.subagentType}
/>
) : null}
{shouldShowOutput ? (
Expand Down Expand Up @@ -312,6 +319,20 @@ const renderToolMessage = ({
) : null}
</>
);

// Sub-agent origin: wrap in a visually demoted container with source label
if (subagentOriginLabel) {
return (
<div className="border-l-2 border-muted-foreground/20 pl-3 opacity-80">
<div className="text-[11px] text-muted-foreground/60 mb-0.5">
{subagentOriginLabel}
</div>
{toolBlock}
</div>
);
}

return toolBlock;
};

const ThinkToolBlock = ({
Expand Down
Loading
Loading