authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-10 01:02:32-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-10 08:02:32+00:00
loga4757c2656864cca1397eb50176a8e60fb514618
tree2f707cde723d90b38e379a74475620b95a910582
parent3efabdcd3a7a6f0ed56443197a65a3a474cddb6c
signature Signed by PGP key B5690EEEBB952194

Composer polish: focus ring, send/stop buttons, command menu, context meter, answer panel (#3018)

Co-authored-by: ss <ss@barekey.dev> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: codex <codex@users.noreply.github.com>

11 files changed, 437 insertions(+), 172 deletions(-)

apps/web/src/components/ChatView.browser.tsx+90-5
......@@ -1416,13 +1416,15 @@ async function waitForButtonByText(text: string): Promise<HTMLButtonElement> {
14161416 return waitForElement(() => findButtonByText(text), `Unable to find "${text}" button.`);
14171417}
14181418
1419function findButtonContainingText(text: string): HTMLButtonElement | null {
1420 return (Array.from(document.querySelectorAll("button")).find((button) =>
1421 button.textContent?.includes(text),
1422 ) ?? null) as HTMLButtonElement | null;
1419function findButtonContainingText(text: string): HTMLElement | null {
1420 return (
1421 Array.from(document.querySelectorAll<HTMLElement>('button, [role="button"]')).find((button) =>
1422 button.textContent?.includes(text),
1423 ) ?? null
1424 );
14231425}
14241426
1425async function waitForButtonContainingText(text: string): Promise<HTMLButtonElement> {
1427async function waitForButtonContainingText(text: string): Promise<HTMLElement> {
14261428 return waitForElement(
14271429 () => findButtonContainingText(text),
14281430 `Unable to find button containing "${text}".`,
......@@ -3219,6 +3221,89 @@ describe("ChatView timeline estimator parity (full app)", () => {
32193221 }
32203222 });
32213223
3224 it("focuses the composer and inserts printable text typed from the page background", async () => {
3225 const mounted = await mountChatView({
3226 viewport: DEFAULT_VIEWPORT,
3227 snapshot: createSnapshotForTargetUser({
3228 targetMessageId: "msg-user-target-type-to-focus" as MessageId,
3229 targetText: "type-to-focus target",
3230 }),
3231 });
3232
3233 const backgroundTarget = document.createElement("div");
3234 backgroundTarget.tabIndex = -1;
3235 document.body.append(backgroundTarget);
3236
3237 try {
3238 const composerEditor = await waitForComposerEditor();
3239 backgroundTarget.focus();
3240 expect(document.activeElement).not.toBe(composerEditor);
3241
3242 const event = new KeyboardEvent("keydown", {
3243 key: "h",
3244 bubbles: true,
3245 cancelable: true,
3246 });
3247 backgroundTarget.dispatchEvent(event);
3248
3249 await waitForComposerText("h");
3250 expect(event.defaultPrevented).toBe(true);
3251 expect(document.activeElement).toBe(composerEditor);
3252
3253 window.dispatchEvent(
3254 new KeyboardEvent("keydown", {
3255 key: "i",
3256 bubbles: true,
3257 cancelable: true,
3258 }),
3259 );
3260
3261 await waitForComposerText("hi");
3262 } finally {
3263 backgroundTarget.remove();
3264 await mounted.cleanup();
3265 }
3266 });
3267
3268 it("does not steal printable keys from editable targets or shortcut modifiers", async () => {
3269 const mounted = await mountChatView({
3270 viewport: DEFAULT_VIEWPORT,
3271 snapshot: createSnapshotForTargetUser({
3272 targetMessageId: "msg-user-target-type-to-focus-guards" as MessageId,
3273 targetText: "type-to-focus guards target",
3274 }),
3275 });
3276 const input = document.createElement("input");
3277 document.body.append(input);
3278
3279 try {
3280 input.focus();
3281 input.dispatchEvent(
3282 new KeyboardEvent("keydown", {
3283 key: "x",
3284 bubbles: true,
3285 cancelable: true,
3286 }),
3287 );
3288 await waitForLayout();
3289 expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe("");
3290
3291 window.dispatchEvent(
3292 new KeyboardEvent("keydown", {
3293 key: "k",
3294 metaKey: true,
3295 bubbles: true,
3296 cancelable: true,
3297 }),
3298 );
3299 await waitForLayout();
3300 expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe("");
3301 } finally {
3302 input.remove();
3303 await mounted.cleanup();
3304 }
3305 });
3306
32223307 it("uses the active draft route session when changing the base branch", async () => {
32233308 const staleDraftId = draftIdFromPath("/draft/draft-stale-branch-session");
32243309 const activeDraftId = draftIdFromPath("/draft/draft-active-branch-session");
apps/web/src/components/ChatView.tsx+60
......@@ -203,6 +203,35 @@ const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = [];
203203const EMPTY_PROVIDERS: ServerProvider[] = [];
204204const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
205205const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
206const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [
207 "input",
208 "textarea",
209 "select",
210 '[contenteditable="true"]',
211 '[contenteditable="plaintext-only"]',
212 '[role="textbox"]',
213].join(",");
214const TYPE_TO_FOCUS_INTERACTIVE_SELECTOR = [
215 "button",
216 "a[href]",
217 "summary",
218 '[role="button"]',
219 '[role="checkbox"]',
220 '[role="menuitem"]',
221 '[role="option"]',
222 '[role="radio"]',
223 '[role="switch"]',
224 '[role="tab"]',
225].join(",");
226const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [
227 '[data-slot="dialog"]',
228 '[data-slot="menu-popup"]',
229 '[data-slot="select-popup"]',
230 '[data-slot="popover-popup"]',
231 '[data-slot="combobox-popup"]',
232 '[data-slot="autocomplete-popup"]',
233].join(",");
234
206235type EnvironmentUnavailableState = {
207236 readonly environmentId: EnvironmentId;
208237 readonly label: string;
......@@ -211,6 +240,25 @@ type EnvironmentUnavailableState = {
211240
212241type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;
213242
243function eventTargetElement(target: EventTarget | null): Element | null {
244 if (target instanceof Element) return target;
245 if (target instanceof Node) return target.parentElement;
246 return null;
247}
248
249function shouldTypeToFocusComposer(event: KeyboardEvent): boolean {
250 if (event.defaultPrevented || event.isComposing) return false;
251 if (event.metaKey || event.ctrlKey || event.altKey) return false;
252 if (event.key.length !== 1) return false;
253
254 const target = eventTargetElement(event.target);
255 if (target?.closest(TYPE_TO_FOCUS_EDITABLE_SELECTOR)) return false;
256 if (target?.closest(TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false;
257 if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false;
258
259 return true;
260}
261
214262function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] {
215263 return useStore(
216264 useMemo(() => {
......@@ -2702,6 +2750,18 @@ export default function ChatView(props: ChatViewProps) {
27022750 modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,
27032751 };
27042752
2753 if (
2754 !shortcutContext.terminalFocus &&
2755 !shortcutContext.modelPickerOpen &&
2756 shouldTypeToFocusComposer(event)
2757 ) {
2758 if (composerRef.current?.insertTextAtEnd(event.key)) {
2759 event.preventDefault();
2760 event.stopPropagation();
2761 return;
2762 }
2763 }
2764
27052765 const command = resolveShortcutCommand(event, keybindings, {
27062766 context: shortcutContext,
27072767 });
apps/web/src/components/chat/ChatComposer.tsx+87-33
......@@ -86,6 +86,7 @@ import {
8686 BotIcon,
8787 CircleAlertIcon,
8888 ListTodoIcon,
89 PencilRulerIcon,
8990 type LucideIcon,
9091 LockIcon,
9192 LockOpenIcon,
......@@ -93,7 +94,7 @@ import {
9394 XIcon,
9495} from "lucide-react";
9596import { proposedPlanTitle } from "../../proposedPlan";
96import { getProviderInteractionModeToggle } from "../../providerModels";
97import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels";
9798import {
9899 deriveProviderInstanceEntries,
99100 resolveProviderDriverKindForInstanceSelection,
......@@ -105,7 +106,10 @@ import type { UnifiedSettings } from "@t3tools/contracts/settings";
105106import type { SessionPhase, Thread } from "../../types";
106107import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
107108import type { PendingApproval, PendingUserInput } from "../../session-logic";
108import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";
109import {
110 deriveLatestContextWindowSnapshot,
111 formatProviderDisplayName,
112} from "../../lib/contextWindow";
109113import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";
110114import { searchProviderSkills } from "../../providerSkillSearch";
111115import { useMediaQuery } from "../../hooks/useMediaQuery";
......@@ -195,36 +199,44 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
195199 ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar`
196200 : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`;
197201
198 return (
202 const interactionModeToggle = props.showInteractionModeToggle ? (
199203 <>
200204 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
205 <Tooltip>
206 <TooltipTrigger
207 render={
208 <Button
209 variant="ghost"
210 className={cn(
211 "shrink-0 whitespace-nowrap px-2 sm:px-3",
212 props.interactionMode === "plan"
213 ? "bg-blue-500/10 text-blue-400 hover:bg-blue-500/15 hover:text-blue-300"
214 : "text-muted-foreground/70 hover:text-foreground/80",
215 )}
216 size="sm"
217 type="button"
218 onClick={props.onToggleInteractionMode}
219 aria-label={interactionModeTooltip}
220 />
221 }
222 >
223 {props.interactionMode === "plan" ? (
224 <PencilRulerIcon className="text-current opacity-100" />
225 ) : (
226 <BotIcon />
227 )}
228 <span className="sr-only sm:not-sr-only">
229 {props.interactionMode === "plan" ? "Plan" : "Build"}
230 </span>
231 </TooltipTrigger>
232 <TooltipPopup side="top">{interactionModeTooltip}</TooltipPopup>
233 </Tooltip>
234 </>
235 ) : null;
201236
202 {props.showInteractionModeToggle ? (
203 <>
204 <Tooltip>
205 <TooltipTrigger
206 render={
207 <Button
208 variant="ghost"
209 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
210 size="sm"
211 type="button"
212 onClick={props.onToggleInteractionMode}
213 aria-label={interactionModeTooltip}
214 />
215 }
216 >
217 <BotIcon />
218 <span className="sr-only sm:not-sr-only">
219 {props.interactionMode === "plan" ? "Plan" : "Build"}
220 </span>
221 </TooltipTrigger>
222 <TooltipPopup side="top">{interactionModeTooltip}</TooltipPopup>
223 </Tooltip>
224
225 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
226 </>
227 ) : null}
237 return (
238 <>
239 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
228240
229241 <Tooltip>
230242 <Select
......@@ -267,6 +279,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
267279 <TooltipPopup side="top">{runtimeModeOption.description}</TooltipPopup>
268280 </Tooltip>
269281
282 {interactionModeToggle}
283
270284 {props.showPlanToggle ? (
271285 <>
272286 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
......@@ -278,7 +292,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
278292 className={cn(
279293 "shrink-0 whitespace-nowrap px-2 sm:px-3",
280294 props.planSidebarOpen
281 ? "text-blue-400 hover:text-blue-300"
295 ? "bg-blue-500/10 text-blue-400 hover:bg-blue-500/15 hover:text-blue-300"
282296 : "text-muted-foreground/70 hover:text-foreground/80",
283297 )}
284298 size="sm"
......@@ -288,7 +302,9 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
288302 />
289303 }
290304 >
291 <ListTodoIcon />
305 <ListTodoIcon
306 className={props.planSidebarOpen ? "text-current opacity-100" : undefined}
307 />
292308 <span className="sr-only sm:not-sr-only">{props.planSidebarLabel}</span>
293309 </TooltipTrigger>
294310 <TooltipPopup side="top">{planSidebarTooltip}</TooltipPopup>
......@@ -302,6 +318,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
302318const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: {
303319 compact: boolean;
304320 activeContextWindow: ReturnType<typeof deriveLatestContextWindowSnapshot>;
321 activeThreadProviderDisplayName: string | null;
305322 isPreparingWorktree: boolean;
306323 pendingAction: {
307324 questionIndex: number;
......@@ -324,7 +341,12 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
324341}) {
325342 return (
326343 <>
327 {props.activeContextWindow ? <ContextWindowMeter usage={props.activeContextWindow} /> : null}
344 {props.activeContextWindow ? (
345 <ContextWindowMeter
346 usage={props.activeContextWindow}
347 providerDisplayName={props.activeThreadProviderDisplayName}
348 />
349 ) : null}
328350 {props.isPreparingWorktree ? (
329351 <span className="text-muted-foreground/70 text-xs">Preparing worktree...</span>
330352 ) : null}
......@@ -355,6 +377,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
355377export interface ChatComposerHandle {
356378 focusAtEnd: () => void;
357379 focusAt: (cursor: number) => void;
380 insertTextAtEnd: (text: string) => boolean;
358381 openModelPicker: () => void;
359382 toggleModelPicker: () => void;
360383 isModelPickerOpen: () => boolean;
......@@ -799,6 +822,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
799822 () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []),
800823 [activeThreadActivities],
801824 );
825 const activeThreadProviderDisplayName = useMemo(() => {
826 if (!activeThreadModelSelection) return null;
827 const entry = providerStatuses.find(
828 (p) => p.instanceId === activeThreadModelSelection.instanceId,
829 );
830 if (entry) {
831 return getProviderDisplayName(providerStatuses, entry.driver);
832 }
833 return formatProviderDisplayName(activeThreadModelSelection.instanceId);
834 }, [providerStatuses, activeThreadModelSelection]);
802835
803836 // ------------------------------------------------------------------
804837 // Composer-local state
......@@ -1856,6 +1889,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18561889 focusAt: (cursor: number) => {
18571890 composerEditorRef.current?.focusAt(cursor);
18581891 },
1892 insertTextAtEnd: (text: string) => {
1893 if (
1894 text.length === 0 ||
1895 isConnecting ||
1896 isComposerApprovalState ||
1897 pendingUserInputs.length > 0 ||
1898 (environmentUnavailable !== null && activePendingProgress === null)
1899 ) {
1900 return false;
1901 }
1902 const rangeEnd = promptRef.current.length;
1903 return applyPromptReplacement(rangeEnd, rangeEnd, text);
1904 },
18591905 openModelPicker: () => {
18601906 setIsComposerModelPickerOpen(true);
18611907 },
......@@ -1940,6 +1986,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
19401986 promptRef,
19411987 composerImagesRef,
19421988 composerTerminalContextsRef,
1989 isConnecting,
1990 isComposerApprovalState,
1991 pendingUserInputs.length,
1992 environmentUnavailable,
1993 activePendingProgress,
1994 applyPromptReplacement,
19431995 isComposerModelPickerOpen,
19441996 readComposerSnapshot,
19451997 selectedModel,
......@@ -2153,7 +2205,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
21532205 )}
21542206 >
21552207 {composerMenuOpen && !isComposerApprovalState && (
2156 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">
2208 <div className="absolute inset-x-0 bottom-full z-20 mb-2">
21572209 <ComposerCommandMenu
21582210 items={composerMenuItems}
21592211 resolvedTheme={resolvedTheme}
......@@ -2325,6 +2377,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
23252377 data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"}
23262378 className={cn(
23272379 "flex min-w-0 flex-nowrap items-center justify-between gap-2 overflow-visible px-2.5 pb-2.5 sm:px-3 sm:pb-3",
2380 pendingUserInputs.length > 0 && "pt-2",
23282381 isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",
23292382 showMobilePendingAnswerActions && "hidden sm:flex",
23302383 )}
......@@ -2400,6 +2453,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
24002453 <ComposerFooterPrimaryActions
24012454 compact={isComposerPrimaryActionsCompact}
24022455 activeContextWindow={activeContextWindow}
2456 activeThreadProviderDisplayName={activeThreadProviderDisplayName}
24032457 pendingAction={pendingPrimaryAction}
24042458 isRunning={phase === "running"}
24052459 showPlanFollowUpPrompt={pendingUserInputs.length === 0 && showPlanFollowUpPrompt}
apps/web/src/components/chat/ComposerCommandMenu.tsx+29-28
......@@ -141,34 +141,35 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
141141 >
142142 <div
143143 ref={listRef}
144 className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs"
144 className="relative w-full overflow-hidden rounded-[20px] border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs"
145145 >
146 <CommandList className="max-h-72">
147 {groups.map((group, groupIndex) => (
148 <div key={group.id}>
149 {groupIndex > 0 ? <CommandSeparator className="my-0.5" /> : null}
150 <CommandGroup>
151 {group.label ? (
152 <CommandGroupLabel className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/55">
153 {group.label}
154 </CommandGroupLabel>
155 ) : null}
156 {group.items.map((item) => (
157 <ComposerCommandMenuItem
158 key={item.id}
159 item={item}
160 resolvedTheme={props.resolvedTheme}
161 isActive={props.activeItemId === item.id}
162 onHighlight={props.onHighlightedItemChange}
163 onSelect={props.onSelect}
164 />
165 ))}
166 </CommandGroup>
167 </div>
168 ))}
169 </CommandList>
170 {props.items.length === 0 ? (
171 <div className="px-3 py-2">
146 {props.items.length > 0 ? (
147 <CommandList className="max-h-72">
148 {groups.map((group, groupIndex) => (
149 <div key={group.id}>
150 {groupIndex > 0 ? <CommandSeparator className="my-0.5" /> : null}
151 <CommandGroup>
152 {group.label ? (
153 <CommandGroupLabel className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/55">
154 {group.label}
155 </CommandGroupLabel>
156 ) : null}
157 {group.items.map((item) => (
158 <ComposerCommandMenuItem
159 key={item.id}
160 item={item}
161 resolvedTheme={props.resolvedTheme}
162 isActive={props.activeItemId === item.id}
163 onHighlight={props.onHighlightedItemChange}
164 onSelect={props.onSelect}
165 />
166 ))}
167 </CommandGroup>
168 </div>
169 ))}
170 </CommandList>
171 ) : (
172 <div className="px-5 py-3.5">
172173 {props.triggerKind === "skill" ? (
173174 <CommandGroup>
174175 <CommandGroupLabel className="px-0 pt-0 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/55">
......@@ -192,7 +193,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
192193 </p>
193194 )}
194195 </div>
195 ) : null}
196 )}
196197 </div>
197198 </Command>
198199 );
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+84-44
......@@ -1,5 +1,5 @@
11import { type ApprovalRequestId } from "@t3tools/contracts";
2import { memo, useEffect, useEffectEvent, useRef } from "react";
2import { memo, useEffect, useEffectEvent, useRef, useState } from "react";
33import { type PendingUserInput } from "../../session-logic";
44import {
55 derivePendingUserInputProgress,
......@@ -61,11 +61,36 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
6161 const activeQuestion = progress.activeQuestion;
6262 const autoAdvanceTimerRef = useRef<number | null>(null);
6363 const onAdvanceRef = useRef(onAdvance);
64 const [optimisticSingleSelect, setOptimisticSingleSelect] = useState<{
65 questionId: string;
66 optionLabel: string;
67 } | null>(null);
6468
6569 useEffect(() => {
6670 onAdvanceRef.current = onAdvance;
6771 }, [onAdvance]);
6872
73 useEffect(() => {
74 if (!activeQuestion || activeQuestion.multiSelect || !optimisticSingleSelect) {
75 return;
76 }
77 if (optimisticSingleSelect.questionId !== activeQuestion.id) {
78 setOptimisticSingleSelect(null);
79 return;
80 }
81 if (
82 progress.customAnswer.trim().length === 0 &&
83 progress.selectedOptionLabels.includes(optimisticSingleSelect.optionLabel)
84 ) {
85 setOptimisticSingleSelect(null);
86 }
87 }, [
88 activeQuestion,
89 optimisticSingleSelect,
90 progress.customAnswer,
91 progress.selectedOptionLabels,
92 ]);
93
6994 // Clear auto-advance timer on unmount
7095 useEffect(() => {
7196 return () => {
......@@ -76,10 +101,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
76101 }, []);
77102
78103 const handleOptionSelection = useEffectEvent((questionId: string, optionLabel: string) => {
79 onToggleOption(questionId, optionLabel);
80104 if (activeQuestion?.multiSelect) {
105 onToggleOption(questionId, optionLabel);
81106 return;
82107 }
108 setOptimisticSingleSelect({ questionId, optionLabel });
109 onToggleOption(questionId, optionLabel);
83110 if (autoAdvanceTimerRef.current !== null) {
84111 window.clearTimeout(autoAdvanceTimerRef.current);
85112 }
......@@ -123,64 +150,77 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
123150 return null;
124151 }
125152
153 const customAnswerActive = progress.customAnswer.trim().length > 0;
154
126155 return (
127156 <div className="px-4 py-3 sm:px-5">
128 <div className="flex items-center gap-3">
129 <div className="flex items-center gap-2">
130 {prompt.questions.length > 1 ? (
131 <span className="flex h-5 items-center rounded-md bg-muted/60 px-1.5 text-[10px] font-medium tabular-nums text-muted-foreground/60">
132 {questionIndex + 1}/{prompt.questions.length}
133 </span>
134 ) : null}
135 <span className="text-[11px] font-semibold tracking-widest text-muted-foreground/50 uppercase">
136 {activeQuestion.header}
157 <div className="mb-2 flex items-center gap-3">
158 <span className="text-[11px] font-semibold tracking-widest text-muted-foreground/55 uppercase">
159 {activeQuestion.header}
160 </span>
161 {prompt.questions.length > 1 ? (
162 <span className="flex h-5 items-center rounded-md bg-muted/60 px-1.5 text-[10px] font-medium tabular-nums text-muted-foreground/60">
163 {questionIndex + 1}/{prompt.questions.length}
137164 </span>
138 </div>
165 ) : null}
139166 </div>
140 <p className="mt-1.5 text-sm text-foreground/90">{activeQuestion.question}</p>
167 <p className="text-sm text-foreground/90">{activeQuestion.question}</p>
141168 {activeQuestion.multiSelect ? (
142169 <p className="mt-1 text-xs text-muted-foreground/65">Select one or more options.</p>
143170 ) : null}
144 <div className="mt-3 space-y-1">
171 <div className="mt-3 space-y-1.5">
145172 {activeQuestion.options.map((option, index) => {
146 const isSelected = progress.selectedOptionLabels.includes(option.label);
173 const isOptimisticallySelected =
174 optimisticSingleSelect?.questionId === activeQuestion.id &&
175 optimisticSingleSelect.optionLabel === option.label;
176 const isSelected =
177 isOptimisticallySelected ||
178 (!customAnswerActive && progress.selectedOptionLabels.includes(option.label));
147179 const shortcutKey = index < 9 ? index + 1 : null;
148 return (
149 <button
150 key={`${activeQuestion.id}:${option.label}`}
151 type="button"
152 disabled={isResponding}
153 onClick={() => handleOptionSelection(activeQuestion.id, option.label)}
154 className={cn(
155 "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left transition-all duration-150",
156 isSelected
157 ? "border-blue-500/40 bg-blue-500/8 text-foreground"
158 : "border-transparent bg-muted/20 text-foreground/80 hover:bg-muted/40 hover:border-border/40",
159 isResponding && "opacity-50 cursor-not-allowed",
160 )}
161 >
162 {shortcutKey !== null ? (
180 const className = cn(
181 "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left outline-none transition-all duration-150 focus-visible:border-primary/40 focus-visible:ring-1 focus-visible:ring-primary/25",
182 isSelected
183 ? "border-primary/30 bg-primary/8 text-foreground"
184 : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34",
185 isResponding && "opacity-50 cursor-not-allowed",
186 !isResponding && "cursor-pointer",
187 );
188 const content = (
189 <>
190 <div className="min-w-0 flex-1 flex flex-col gap-0.5">
191 <span className="text-sm font-medium">{option.label}</span>
192 {option.description && option.description !== option.label ? (
193 <span className="text-xs text-muted-foreground/50">{option.description}</span>
194 ) : null}
195 </div>
196 {isSelected ? (
197 <CheckIcon className="size-3.5 shrink-0 text-primary" />
198 ) : shortcutKey !== null ? (
163199 <kbd
164200 className={cn(
165 "flex size-5 shrink-0 items-center justify-center rounded text-[11px] font-medium tabular-nums transition-colors duration-150",
166 isSelected
167 ? "bg-blue-500/20 text-blue-400"
168 : "bg-muted/40 text-muted-foreground/50 group-hover:bg-muted/60 group-hover:text-muted-foreground/70",
201 "flex size-5 shrink-0 items-center justify-center rounded border border-border/50 text-[11px] font-medium tabular-nums transition-colors duration-150",
202 "bg-background/35 text-muted-foreground/70 group-hover:border-border/70 group-hover:text-muted-foreground",
169203 )}
170204 >
171205 {shortcutKey}
172206 </kbd>
173207 ) : null}
174 <div className="min-w-0 flex-1">
175 <span className="text-sm font-medium">{option.label}</span>
176 {option.description && option.description !== option.label ? (
177 <span className="ml-2 text-xs text-muted-foreground/50">
178 {option.description}
179 </span>
180 ) : null}
181 </div>
182 {isSelected ? <CheckIcon className="size-3.5 shrink-0 text-blue-400" /> : null}
183 </button>
208 </>
209 );
210 return (
211 <div
212 key={`${activeQuestion.id}:${option.label}`}
213 role="button"
214 tabIndex={isResponding ? -1 : 0}
215 aria-disabled={isResponding}
216 onClick={() => {
217 if (isResponding) return;
218 handleOptionSelection(activeQuestion.id, option.label);
219 }}
220 className={className}
221 >
222 {content}
223 </div>
184224 );
185225 })}
186226 </div>
apps/web/src/components/chat/ComposerPrimaryActions.tsx+4-20
......@@ -3,6 +3,7 @@ import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react";
33import { cn } from "~/lib/utils";
44import { Button } from "../ui/button";
55import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
6import { Spinner } from "../ui/spinner";
67
78interface PendingActionState {
89 questionIndex: number;
......@@ -126,7 +127,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
126127 return (
127128 <button
128129 type="button"
129 className="flex size-8 cursor-pointer items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105 sm:h-8 sm:w-8"
130 className="flex size-8 cursor-pointer items-center justify-center rounded-full bg-destructive/90 text-white shadow-xs shadow-destructive/24 inset-shadow-[0_1px_--theme(--color-white/16%)] transition-all duration-150 hover:bg-destructive hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none sm:h-8 sm:w-8"
130131 {...pointerFocusProps}
131132 onClick={onInterrupt}
132133 aria-label="Stop generation"
......@@ -195,7 +196,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
195196 return (
196197 <button
197198 type="submit"
198 className="flex h-9 w-9 enabled:cursor-pointer items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:pointer-events-none disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8"
199 className="flex h-9 w-9 enabled:cursor-pointer items-center justify-center rounded-full bg-primary/90 text-primary-foreground shadow-xs enabled:shadow-primary/24 enabled:inset-shadow-[0_1px_--theme(--color-white/16%)] transition-all duration-150 hover:bg-primary hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none disabled:pointer-events-none disabled:opacity-30 disabled:shadow-none disabled:hover:scale-100 sm:h-8 sm:w-8"
199200 {...pointerFocusProps}
200201 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || !hasSendableContent}
201202 aria-label={
......@@ -211,24 +212,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
211212 }
212213 >
213214 {isConnecting || isSendBusy ? (
214 <svg
215 width="14"
216 height="14"
217 viewBox="0 0 14 14"
218 fill="none"
219 className="animate-spin"
220 aria-hidden="true"
221 >
222 <circle
223 cx="7"
224 cy="7"
225 r="5.5"
226 stroke="currentColor"
227 strokeWidth="1.5"
228 strokeLinecap="round"
229 strokeDasharray="20 12"
230 />
231 </svg>
215 <Spinner className="size-3.5" aria-hidden="true" />
232216 ) : (
233217 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
234218 <path
apps/web/src/components/chat/ContextWindowMeter.tsx+58-40
......@@ -12,13 +12,20 @@ function formatPercentage(value: number | null): string | null {
1212 return `${Math.round(value)}%`;
1313}
1414
15export function ContextWindowMeter(props: { usage: ContextWindowSnapshot }) {
16 const { usage } = props;
15export function ContextWindowMeter(props: {
16 usage: ContextWindowSnapshot;
17 providerDisplayName?: string | null;
18}) {
19 const { usage, providerDisplayName } = props;
1720 const usedPercentage = formatPercentage(usage.usedPercentage);
1821 const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0));
1922 const radius = 9.75;
2023 const circumference = 2 * Math.PI * radius;
2124 const dashOffset = circumference - (normalizedPercentage / 100) * circumference;
25 const totalProcessedTokens = usage.totalProcessedTokens ?? null;
26 const showTotalProcessed = totalProcessedTokens !== null && totalProcessedTokens > 0;
27 const isOverloaded = normalizedPercentage > 90;
28 const usageColor = isOverloaded ? "var(--color-red-500)" : "var(--color-blue-500)";
2229
2330 return (
2431 <Popover>
......@@ -29,17 +36,21 @@ export function ContextWindowMeter(props: { usage: ContextWindowSnapshot }) {
2936 render={
3037 <button
3138 type="button"
32 className="group inline-flex items-center justify-center rounded-full transition-opacity hover:opacity-85"
39 className={cn(
40 "inline-flex size-6 cursor-pointer items-center justify-center rounded-full border border-transparent text-muted-foreground outline-none transition-colors",
41 "hover:bg-accent data-[pressed]:bg-accent",
42 "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
43 )}
3344 aria-label={
3445 usage.maxTokens !== null && usedPercentage
3546 ? `Context window ${usedPercentage} used`
3647 : `Context window ${formatContextWindowTokens(usage.usedTokens)} tokens used`
3748 }
3849 >
39 <span className="relative flex h-6 w-6 items-center justify-center">
50 <span className="relative flex size-4 items-center justify-center">
4051 <svg
4152 viewBox="0 0 24 24"
42 className="-rotate-90 absolute inset-0 h-full w-full transform-gpu"
53 className="-rotate-90 absolute inset-0 size-full transform-gpu"
4354 aria-hidden="true"
4455 >
4556 <circle
......@@ -47,7 +58,7 @@ export function ContextWindowMeter(props: { usage: ContextWindowSnapshot }) {
4758 cy="12"
4859 r={radius}
4960 fill="none"
50 stroke="color-mix(in oklab, var(--color-muted) 70%, transparent)"
61 stroke="color-mix(in oklab, var(--color-muted-foreground) 35%, transparent)"
5162 strokeWidth="3"
5263 />
5364 <circle
......@@ -55,7 +66,7 @@ export function ContextWindowMeter(props: { usage: ContextWindowSnapshot }) {
5566 cy="12"
5667 r={radius}
5768 fill="none"
58 stroke="var(--color-muted-foreground)"
69 stroke={usageColor}
5970 strokeWidth="3"
6071 strokeLinecap="round"
6172 strokeDasharray={circumference}
......@@ -63,48 +74,55 @@ export function ContextWindowMeter(props: { usage: ContextWindowSnapshot }) {
6374 className="transition-[stroke-dashoffset] duration-500 ease-out motion-reduce:transition-none"
6475 />
6576 </svg>
66 <span
67 className={cn(
68 "relative flex h-[15px] w-[15px] items-center justify-center rounded-full bg-background text-[8px] font-medium",
69 "text-muted-foreground",
70 )}
71 >
72 {usage.usedPercentage !== null
73 ? Math.round(usage.usedPercentage)
74 : formatContextWindowTokens(usage.usedTokens)}
75 </span>
7677 </span>
7778 </button>
7879 }
7980 />
80 <PopoverPopup tooltipStyle side="top" align="end" className="w-max max-w-none px-3 py-2">
81 <div className="space-y-1.5 leading-tight">
82 <div className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
83 Context window
81 <PopoverPopup tooltipStyle side="top" align="end" className="w-64 max-w-none p-0">
82 <div className="flex flex-col gap-2 p-3">
83 <div className="flex items-center justify-between gap-3">
84 <div className="font-medium text-muted-foreground text-xs">Context Window</div>
85 {usage.maxTokens !== null && usedPercentage ? (
86 <div className="text-[11px] tabular-nums text-muted-foreground/70">
87 <span>{usedPercentage}</span>
88 <span className="mx-1">·</span>
89 <span>
90 {formatContextWindowTokens(usage.usedTokens)}/
91 {formatContextWindowTokens(usage.maxTokens ?? null)}
92 </span>
93 </div>
94 ) : (
95 <div className="text-[11px] tabular-nums text-muted-foreground/70">
96 {formatContextWindowTokens(usage.usedTokens)}
97 </div>
98 )}
8499 </div>
85 {usage.maxTokens !== null && usedPercentage ? (
86 <div className="whitespace-nowrap text-xs font-medium text-foreground">
87 <span>{usedPercentage}</span>
88 <span className="mx-1">⋅</span>
89 <span>{formatContextWindowTokens(usage.usedTokens)}</span>
90 <span>/</span>
91 <span>{formatContextWindowTokens(usage.maxTokens ?? null)} context used</span>
92 </div>
93 ) : (
94 <div className="text-sm text-foreground">
95 {formatContextWindowTokens(usage.usedTokens)} tokens used so far
100 {usage.maxTokens !== null ? (
101 <div
102 className="h-1.5 w-full overflow-hidden rounded-full bg-muted/60"
103 role="progressbar"
104 aria-valuemin={0}
105 aria-valuemax={100}
106 aria-valuenow={Math.round(normalizedPercentage)}
107 aria-label="Context window usage"
108 >
109 <div
110 className="h-full rounded-full transition-[width,background-color] duration-500 ease-out motion-reduce:transition-none"
111 style={{ width: `${normalizedPercentage}%`, backgroundColor: usageColor }}
112 />
96113 </div>
97 )}
98 {(usage.totalProcessedTokens ?? null) !== null &&
99 (usage.totalProcessedTokens ?? 0) > usage.usedTokens ? (
100 <div className="text-xs text-muted-foreground">
101 Total processed: {formatContextWindowTokens(usage.totalProcessedTokens ?? null)}{" "}
102 tokens
114 ) : null}
115 {showTotalProcessed ? (
116 <div className="flex items-center justify-between gap-3 text-[11px] leading-4">
117 <span className="text-muted-foreground/60">Total processed</span>
118 <span className="font-medium tabular-nums text-muted-foreground/80">
119 {formatContextWindowTokens(totalProcessedTokens)}
120 </span>
103121 </div>
104122 ) : null}
105123 {usage.compactsAutomatically ? (
106 <div className="text-xs text-muted-foreground">
107 Automatically compacts its context when needed.
124 <div className="mt-1 text-pretty text-[11px] font-medium text-muted-foreground/70">
125 {providerDisplayName ?? "It"} automatically compacts its context when needed.
108126 </div>
109127 ) : null}
110128 </div>
apps/web/src/components/chat/composerProviderState.test.tsx+1-1
......@@ -56,7 +56,7 @@ function selections(
5656
5757const ULTRATHINK_FRAME_CLASSES = {
5858 composerFrameClassName: "ultrathink-frame",
59 composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]",
59 composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.07)_inset]",
6060 modelPickerIconClassName: "ultrathink-chroma",
6161} as const;
6262
apps/web/src/components/chat/composerProviderState.tsx+1-1
......@@ -67,7 +67,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com
6767 ...(ultrathinkActive
6868 ? {
6969 composerFrameClassName: "ultrathink-frame",
70 composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]",
70 composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.07)_inset]",
7171 modelPickerIconClassName: "ultrathink-chroma",
7272 }
7373 : {}),
apps/web/src/index.css+1
......@@ -685,6 +685,7 @@ label:has(> select#reasoning-effort) select {
685685 );
686686 background-image: var(--ultrathink-spectrum);
687687 background-size: 220% 220%;
688 filter: saturate(0.82) brightness(0.92);
688689 animation: ultrathink-rainbow 10s linear infinite;
689690}
690691
apps/web/src/lib/contextWindow.ts+22
......@@ -25,6 +25,28 @@ export type ContextWindowSnapshot = NullableContextWindowUsage & {
2525 readonly updatedAt: string;
2626};
2727
28/** Map a provider driver kind to a user-facing display name. */
29export function formatProviderDisplayName(provider: string | null | undefined): string {
30 if (!provider) return "This agent";
31 switch (provider) {
32 case "claudeAgent":
33 case "claude":
34 return "Claude";
35 case "codex":
36 return "Codex";
37 case "cursor":
38 return "Cursor";
39 case "opencode":
40 return "OpenCode";
41 default: {
42 // Title-case unknown driver kinds so they read reasonably.
43 const trimmed = provider.replace(/Agent$/i, "").trim();
44 if (trimmed.length === 0) return provider;
45 return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
46 }
47 }
48}
49
2850export function deriveLatestContextWindowSnapshot(
2951 activities: ReadonlyArray<OrchestrationThreadActivity>,
3052): ContextWindowSnapshot | null {