Skip to content

Commit 1f8530e

Browse files
authored
feat(web): pending approvals show on their timeline row (#26255)
1 parent 8031991 commit 1f8530e

14 files changed

Lines changed: 546 additions & 185 deletions

‎apps/web/src/components/ChatView.tsx‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ import { useLocation, useNavigate } from "@tanstack/react-router";
115115
import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation";
116116
import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
117117
import { useStore } from "zustand";
118+
import { shallow } from "zustand/vanilla/shallow";
118119
import { useShallow } from "zustand/react/shallow";
119120
import {
120121
isAtomCommandInterrupted,
@@ -1314,6 +1315,12 @@ export default function ChatView(props: ChatViewProps) {
13141315
composerRef,
13151316
setThreadError,
13161317
});
1318+
const [heldApprovalRequestIds, setHeldApprovalRequestIds] = useState<ReadonlySet<string>>(
1319+
() => new Set(),
1320+
);
1321+
const onHeldRequestIdsChange = useStableCallback((requestIds: ReadonlySet<string>) => {
1322+
setHeldApprovalRequestIds((current) => (shallow(current, requestIds) ? current : requestIds));
1323+
});
13171324
const activeProposedPlan = (() => {
13181325
if (!latestRunSettled) {
13191326
return null;
@@ -3245,6 +3252,11 @@ export default function ChatView(props: ChatViewProps) {
32453252
className="relative flex min-h-0 flex-1 flex-col bg-background"
32463253
>
32473254
<ThreadTimeline
3255+
pendingApprovals={pendingApprovals}
3256+
heldApprovalRequestIds={heldApprovalRequestIds}
3257+
respondingRequestIds={respondingRequestIds}
3258+
onRespondToApproval={onRespondToApprovalStable}
3259+
canOperateThread={canOperateThread}
32483260
threadDetailRef={routeThreadDetailRef}
32493261
entriesInput={timelineEntriesInput}
32503262
draftEntries={isServerThread ? null : draftTimelineEntries}
@@ -3489,6 +3501,7 @@ export default function ChatView(props: ChatViewProps) {
34893501
environmentUnavailable={activeEnvironmentUnavailableState}
34903502
activePendingApproval={activePendingApproval}
34913503
pendingApprovals={pendingApprovals}
3504+
onHeldRequestIdsChange={onHeldRequestIdsChange}
34923505
pendingUserInputs={pendingUserInputs}
34933506
activePendingProgress={activePendingProgress}
34943507
activePendingResolvedAnswers={activePendingResolvedAnswers}
@@ -3550,7 +3563,6 @@ export default function ChatView(props: ChatViewProps) {
35503563
onResume={onComposerResumeStable}
35513564
onInterrupt={onInterruptStable}
35523565
onImplementPlanInNewThread={onImplementPlanInNewThreadStable}
3553-
onRespondToApproval={onRespondToApprovalStable}
35543566
onSelectActivePendingUserInputOption={
35553567
onSelectActivePendingUserInputOptionStable
35563568
}

‎apps/web/src/components/chat/ChatComposer.tsx‎

Lines changed: 12 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import type {
2020
ModelSelection,
2121
ProjectId,
2222
PreviewAnnotationPayload,
23-
ProviderApprovalDecision,
2423
ThreadContextRecord,
2524
EnvironmentContextRecord,
2625
ProviderInteractionMode,
@@ -187,7 +186,6 @@ import {
187186
ComposerCommandMenu,
188187
composerSuggestionOptionId,
189188
} from "./ComposerCommandMenu";
190-
import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
191189
import {
192190
ComposerAttachButton,
193191
ComposerAttachmentFileList,
@@ -198,7 +196,6 @@ import {
198196
} from "./ComposerAttachmentStrip";
199197
import { ComposerPrimaryActions } from "./ComposerPrimaryActions";
200198
import { ComposerFooterPrimaryActions } from "./ComposerFooterControls";
201-
import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";
202199
import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
203200
import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";
204201
import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight";
@@ -239,7 +236,7 @@ const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidErro
239236
const COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX = 24;
240237
const COMPOSER_SCROLL_GESTURE_RESET_MS = 120;
241238
import { toastManager } from "../ui/toast";
242-
import { PlayIcon, ShieldIcon } from "lucide-react";
239+
import { PlayIcon } from "lucide-react";
243240
import { proposedPlanTitle } from "@t3tools/shared/proposedPlanText";
244241
import { hasProviderSetup } from "./ProviderStatusBanner";
245242
import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection";
@@ -402,6 +399,7 @@ export interface ChatComposerProps {
402399

403400
activePendingApproval: PendingApproval | null;
404401
pendingApprovals: PendingApproval[];
402+
onHeldRequestIdsChange: (requestIds: ReadonlySet<string>) => void;
405403
pendingUserInputs: PendingUserInput[];
406404
activePendingProgress: {
407405
questionIndex: number;
@@ -482,10 +480,6 @@ export interface ChatComposerProps {
482480
onResume: () => void;
483481
onInterrupt: () => void;
484482
onImplementPlanInNewThread: () => void;
485-
onRespondToApproval: (
486-
requestId: RuntimeRequestId,
487-
decision: ProviderApprovalDecision,
488-
) => Promise<unknown>;
489483
onSelectActivePendingUserInputOption: (questionId: string, optionValue: string) => void;
490484
onAdvanceActivePendingUserInput: () => void;
491485
onDismissActivePendingUserInput: (requestId: RuntimeRequestId) => void;
@@ -550,6 +544,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
550544
environmentUnavailable,
551545
activePendingApproval: incomingPendingApproval,
552546
pendingApprovals,
547+
onHeldRequestIdsChange,
553548
pendingUserInputs: incomingPendingUserInputs,
554549
activePendingProgress: incomingPendingProgress,
555550
activePendingResolvedAnswers,
@@ -601,7 +596,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
601596
onResume,
602597
onInterrupt,
603598
onImplementPlanInNewThread,
604-
onRespondToApproval,
605599
onSelectActivePendingUserInputOption,
606600
onAdvanceActivePendingUserInput,
607601
onDismissActivePendingUserInput,
@@ -633,6 +627,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
633627
...pendingApprovals.map((request) => request.requestId),
634628
...incomingPendingUserInputs.map((request) => request.requestId),
635629
]);
630+
// A layout effect so the timeline never paints a held approval as answerable.
631+
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes held requests to the timeline without lifting per-keystroke typing state
632+
useLayoutEffect(() => {
633+
onHeldRequestIdsChange(heldRequestIds);
634+
}, [heldRequestIds, onHeldRequestIdsChange]);
636635
const activePendingApproval =
637636
incomingPendingApproval && !heldRequestIds.has(incomingPendingApproval.requestId)
638637
? incomingPendingApproval
@@ -1200,7 +1199,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
12001199
const isChoiceOnlyPendingQuestion =
12011200
activePendingProgress?.activeQuestion?.allowCustomAnswer === false;
12021201
const showComposerTopDrawer =
1203-
isComposerApprovalState ||
12041202
pendingUserInputs.length > 0 ||
12051203
(!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null);
12061204
const showCollapsedMobilePromptRow = isComposerCollapsedMobile;
@@ -2320,8 +2318,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
23202318
importContextRecords,
23212319
expandMobileComposer: expandMobileComposerStable,
23222320
});
2323-
const hasBlockingComposerTopDrawer =
2324-
activePendingApproval !== null || pendingUserInputs.length > 0;
2321+
const hasBlockingComposerTopDrawer = pendingUserInputs.length > 0;
23252322
const hasImageAttachmentAttention = standaloneComposerImages.some((image) => {
23262323
const upload = uploadsByImageId[image.id];
23272324
const failedInCurrentEnvironment =
@@ -2580,7 +2577,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25802577
getModelDisabledReason={getModelDisabledReason}
25812578
/>
25822579
);
2583-
// An open approval or question owns the space above the composer. Only a machine problem,
2580+
// An open question owns the space above the composer. Only a machine problem,
25842581
// which is why an answer could not go through, stays beside it.
25852582
const shelfItems = hasBlockingComposerTopDrawer
25862583
? props.bannerItems.filter(
@@ -3444,39 +3441,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
34443441
<ComposerBannerStack items={shelfItems} />
34453442
{showComposerTopDrawer ? (
34463443
<ComposerBanner.Attachment>
3447-
<ComposerBanner.Root
3448-
data-chat-composer-top-drawer="true"
3449-
variant={activePendingApproval ? "warning" : "info"}
3450-
density={activePendingApproval ? "spacious" : "default"}
3451-
>
3452-
{activePendingApproval ? (
3453-
<ComposerBanner.Row
3454-
layout="approval"
3455-
data-chat-composer-collapsed-controls="true"
3456-
>
3457-
<ComposerBanner.Icon>
3458-
<ShieldIcon />
3459-
</ComposerBanner.Icon>
3460-
<ComposerBanner.Content>
3461-
<ComposerPendingApprovalPanel
3462-
approval={activePendingApproval}
3463-
pendingCount={pendingApprovals.length}
3464-
/>
3465-
</ComposerBanner.Content>
3466-
<ComposerBanner.Actions>
3467-
<ComposerPendingApprovalActions
3468-
requestId={activePendingApproval.requestId}
3469-
disabled={!canOperateThread}
3470-
isResponding={respondingRequestIds.includes(
3471-
activePendingApproval.requestId,
3472-
)}
3473-
options={activePendingApproval.options}
3474-
canRespond={activePendingApproval.responseCapability === "live"}
3475-
onRespondToApproval={onRespondToApproval}
3476-
/>
3477-
</ComposerBanner.Actions>
3478-
</ComposerBanner.Row>
3479-
) : pendingUserInputs.length > 0 ? (
3444+
<ComposerBanner.Root data-chat-composer-top-drawer="true" variant="info">
3445+
{pendingUserInputs.length > 0 ? (
34803446
<ComposerPendingUserInputPanel
34813447
pendingUserInputs={pendingUserInputs}
34823448
cwd={gitCwd ?? undefined}

‎apps/web/src/components/chat/ComposerPendingApprovalActions.tsx‎

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ import {
55
} from "@t3tools/contracts";
66
import { memo } from "react";
77
import { EllipsisIcon, TriangleAlertIcon } from "lucide-react";
8-
import { Button } from "../ui/button";
8+
import { Button, InlineButton } from "../ui/button";
99
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
1010
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1111
import { composerFloatingLayerProps } from "./composerEventScope";
1212

1313
interface ComposerPendingApprovalActionsProps {
14+
presentation?: "composer" | "inline";
1415
requestId: RuntimeRequestId;
1516
isResponding: boolean;
1617
canRespond: boolean;
@@ -30,6 +31,7 @@ const DEFAULT_APPROVAL_OPTIONS = [
3031
] satisfies ReadonlyArray<ProviderApprovalOption>;
3132

3233
export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({
34+
presentation = "composer",
3335
requestId,
3436
isResponding,
3537
canRespond,
@@ -47,23 +49,42 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro
4749
return (
4850
<>
4951
{primaryOptions.map((option) => {
50-
const button = (
51-
<Button
52-
key={option.decision}
53-
size="xs"
54-
variant={option.decision === "accept" ? "default" : "outline"}
55-
disabled={disabled || isResponding || !canRespond}
56-
aria-description={option.warning}
57-
onClick={() => {
58-
if (!disabled && !isResponding) {
59-
void onRespondToApproval(requestId, option.decision);
60-
}
61-
}}
62-
>
52+
const buttonProps = {
53+
disabled: disabled || isResponding || !canRespond,
54+
"aria-description": option.warning,
55+
onClick: () => {
56+
if (!disabled && !isResponding && canRespond) {
57+
void onRespondToApproval(requestId, option.decision);
58+
}
59+
},
60+
};
61+
const content = (
62+
<>
6363
{option.warning ? <TriangleAlertIcon className="size-3 shrink-0" /> : null}
64-
<span className="max-w-40 truncate">{option.label}</span>
65-
</Button>
64+
<span className={presentation === "inline" ? "whitespace-normal" : "max-w-40 truncate"}>
65+
{option.label}
66+
</span>
67+
</>
6668
);
69+
const button =
70+
presentation === "inline" ? (
71+
<InlineButton
72+
key={option.decision}
73+
{...buttonProps}
74+
tone={option.decision === "accept" ? "default" : "muted"}
75+
>
76+
{content}
77+
</InlineButton>
78+
) : (
79+
<Button
80+
key={option.decision}
81+
{...buttonProps}
82+
size="xs"
83+
variant={option.decision === "accept" ? "default" : "outline"}
84+
>
85+
{content}
86+
</Button>
87+
);
6788
return option.warning ? (
6889
<Tooltip key={option.decision}>
6990
<TooltipTrigger render={button} />
@@ -76,20 +97,26 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro
7697
{moreOptions.length > 0 ? (
7798
<Menu>
7899
<MenuTrigger
79-
disabled={disabled || isResponding}
80-
render={<Button size="icon-xs" variant="outline" aria-label="More approval options" />}
100+
disabled={disabled || isResponding || !canRespond}
101+
render={
102+
presentation === "inline" ? (
103+
<InlineButton tone="muted" aria-label="More approval options" />
104+
) : (
105+
<Button size="icon-xs" variant="outline" aria-label="More approval options" />
106+
)
107+
}
81108
>
82-
<EllipsisIcon />
109+
{presentation === "inline" ? "More" : <EllipsisIcon />}
83110
</MenuTrigger>
84111
<MenuPopup {...composerFloatingLayerProps} side="top" align="end" width="content">
85112
{moreOptions.map((option) => {
86113
const item = (
87114
<MenuItem
88115
key={option.decision}
89-
disabled={disabled || isResponding}
116+
disabled={disabled || isResponding || !canRespond}
90117
aria-description={option.warning}
91118
onClick={() => {
92-
if (!disabled && !isResponding)
119+
if (!disabled && !isResponding && canRespond)
93120
void onRespondToApproval(requestId, option.decision);
94121
}}
95122
>

0 commit comments

Comments
 (0)