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> {...@@ -1416,13 +1416,15 @@ async function waitForButtonByText(text: string): Promise<HTMLButtonElement> {
1416 return waitForElement(() => findButtonByText(text), `Unable to find "${text}" button.`);1416 return waitForElement(() => findButtonByText(text), `Unable to find "${text}" button.`);
1417}1417}
14181418
1419function findButtonContainingText(text: string): HTMLButtonElement | null {1419function findButtonContainingText(text: string): HTMLElement | null {
1420 return (Array.from(document.querySelectorAll("button")).find((button) =>1420 return (
1421 button.textContent?.includes(text),1421 Array.from(document.querySelectorAll<HTMLElement>('button, [role="button"]')).find((button) =>
1422 ) ?? null) as HTMLButtonElement | null;1422 button.textContent?.includes(text),
1423 ) ?? null
1424 );
1423}1425}
14241426
1425async function waitForButtonContainingText(text: string): Promise<HTMLButtonElement> {1427async function waitForButtonContainingText(text: string): Promise<HTMLElement> {
1426 return waitForElement(1428 return waitForElement(
1427 () => findButtonContainingText(text),1429 () => findButtonContainingText(text),
1428 `Unable to find button containing "${text}".`,1430 `Unable to find button containing "${text}".`,
...@@ -3219,6 +3221,89 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -3219,6 +3221,89 @@ describe("ChatView timeline estimator parity (full app)", () => {
3219 }3221 }
3220 });3222 });
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
3222 it("uses the active draft route session when changing the base branch", async () => {3307 it("uses the active draft route session when changing the base branch", async () => {
3223 const staleDraftId = draftIdFromPath("/draft/draft-stale-branch-session");3308 const staleDraftId = draftIdFromPath("/draft/draft-stale-branch-session");
3224 const activeDraftId = draftIdFromPath("/draft/draft-active-branch-session");3309 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"] = [];...@@ -203,6 +203,35 @@ const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = [];
203const EMPTY_PROVIDERS: ServerProvider[] = [];203const EMPTY_PROVIDERS: ServerProvider[] = [];
204const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];204const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
205const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};205const 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
206type EnvironmentUnavailableState = {235type EnvironmentUnavailableState = {
207 readonly environmentId: EnvironmentId;236 readonly environmentId: EnvironmentId;
208 readonly label: string;237 readonly label: string;
...@@ -211,6 +240,25 @@ type EnvironmentUnavailableState = {...@@ -211,6 +240,25 @@ type EnvironmentUnavailableState = {
211240
212type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;241type 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
214function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] {262function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] {
215 return useStore(263 return useStore(
216 useMemo(() => {264 useMemo(() => {
...@@ -2702,6 +2750,18 @@ export default function ChatView(props: ChatViewProps) {...@@ -2702,6 +2750,18 @@ export default function ChatView(props: ChatViewProps) {
2702 modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,2750 modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,
2703 };2751 };
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
2705 const command = resolveShortcutCommand(event, keybindings, {2765 const command = resolveShortcutCommand(event, keybindings, {
2706 context: shortcutContext,2766 context: shortcutContext,
2707 });2767 });
apps/web/src/components/chat/ChatComposer.tsx+87-33
...@@ -86,6 +86,7 @@ import {...@@ -86,6 +86,7 @@ import {
86 BotIcon,86 BotIcon,
87 CircleAlertIcon,87 CircleAlertIcon,
88 ListTodoIcon,88 ListTodoIcon,
89 PencilRulerIcon,
89 type LucideIcon,90 type LucideIcon,
90 LockIcon,91 LockIcon,
91 LockOpenIcon,92 LockOpenIcon,
...@@ -93,7 +94,7 @@ import {...@@ -93,7 +94,7 @@ import {
93 XIcon,94 XIcon,
94} from "lucide-react";95} from "lucide-react";
95import { proposedPlanTitle } from "../../proposedPlan";96import { proposedPlanTitle } from "../../proposedPlan";
96import { getProviderInteractionModeToggle } from "../../providerModels";97import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels";
97import {98import {
98 deriveProviderInstanceEntries,99 deriveProviderInstanceEntries,
99 resolveProviderDriverKindForInstanceSelection,100 resolveProviderDriverKindForInstanceSelection,
...@@ -105,7 +106,10 @@ import type { UnifiedSettings } from "@t3tools/contracts/settings";...@@ -105,7 +106,10 @@ import type { UnifiedSettings } from "@t3tools/contracts/settings";
105import type { SessionPhase, Thread } from "../../types";106import type { SessionPhase, Thread } from "../../types";
106import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";107import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
107import type { PendingApproval, PendingUserInput } from "../../session-logic";108import type { PendingApproval, PendingUserInput } from "../../session-logic";
108import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";109import {
110 deriveLatestContextWindowSnapshot,
111 formatProviderDisplayName,
112} from "../../lib/contextWindow";
109import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";113import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";
110import { searchProviderSkills } from "../../providerSkillSearch";114import { searchProviderSkills } from "../../providerSkillSearch";
111import { useMediaQuery } from "../../hooks/useMediaQuery";115import { useMediaQuery } from "../../hooks/useMediaQuery";
...@@ -195,36 +199,44 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -195,36 +199,44 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
195 ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar`199 ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar`
196 : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`;200 : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`;
197201
198 return (202 const interactionModeToggle = props.showInteractionModeToggle ? (
199 <>203 <>
200 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />204 <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 ? (237 return (
203 <>238 <>
204 <Tooltip>239 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
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}
228240
229 <Tooltip>241 <Tooltip>
230 <Select242 <Select
...@@ -267,6 +279,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -267,6 +279,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
267 <TooltipPopup side="top">{runtimeModeOption.description}</TooltipPopup>279 <TooltipPopup side="top">{runtimeModeOption.description}</TooltipPopup>
268 </Tooltip>280 </Tooltip>
269281
282 {interactionModeToggle}
283
270 {props.showPlanToggle ? (284 {props.showPlanToggle ? (
271 <>285 <>
272 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />286 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
...@@ -278,7 +292,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -278,7 +292,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
278 className={cn(292 className={cn(
279 "shrink-0 whitespace-nowrap px-2 sm:px-3",293 "shrink-0 whitespace-nowrap px-2 sm:px-3",
280 props.planSidebarOpen294 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"
282 : "text-muted-foreground/70 hover:text-foreground/80",296 : "text-muted-foreground/70 hover:text-foreground/80",
283 )}297 )}
284 size="sm"298 size="sm"
...@@ -288,7 +302,9 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -288,7 +302,9 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
288 />302 />
289 }303 }
290 >304 >
291 <ListTodoIcon />305 <ListTodoIcon
306 className={props.planSidebarOpen ? "text-current opacity-100" : undefined}
307 />
292 <span className="sr-only sm:not-sr-only">{props.planSidebarLabel}</span>308 <span className="sr-only sm:not-sr-only">{props.planSidebarLabel}</span>
293 </TooltipTrigger>309 </TooltipTrigger>
294 <TooltipPopup side="top">{planSidebarTooltip}</TooltipPopup>310 <TooltipPopup side="top">{planSidebarTooltip}</TooltipPopup>
...@@ -302,6 +318,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -302,6 +318,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
302const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: {318const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: {
303 compact: boolean;319 compact: boolean;
304 activeContextWindow: ReturnType<typeof deriveLatestContextWindowSnapshot>;320 activeContextWindow: ReturnType<typeof deriveLatestContextWindowSnapshot>;
321 activeThreadProviderDisplayName: string | null;
305 isPreparingWorktree: boolean;322 isPreparingWorktree: boolean;
306 pendingAction: {323 pendingAction: {
307 questionIndex: number;324 questionIndex: number;
...@@ -324,7 +341,12 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -324,7 +341,12 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
324}) {341}) {
325 return (342 return (
326 <>343 <>
327 {props.activeContextWindow ? <ContextWindowMeter usage={props.activeContextWindow} /> : null}344 {props.activeContextWindow ? (
345 <ContextWindowMeter
346 usage={props.activeContextWindow}
347 providerDisplayName={props.activeThreadProviderDisplayName}
348 />
349 ) : null}
328 {props.isPreparingWorktree ? (350 {props.isPreparingWorktree ? (
329 <span className="text-muted-foreground/70 text-xs">Preparing worktree...</span>351 <span className="text-muted-foreground/70 text-xs">Preparing worktree...</span>
330 ) : null}352 ) : null}
...@@ -355,6 +377,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -355,6 +377,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
355export interface ChatComposerHandle {377export interface ChatComposerHandle {
356 focusAtEnd: () => void;378 focusAtEnd: () => void;
357 focusAt: (cursor: number) => void;379 focusAt: (cursor: number) => void;
380 insertTextAtEnd: (text: string) => boolean;
358 openModelPicker: () => void;381 openModelPicker: () => void;
359 toggleModelPicker: () => void;382 toggleModelPicker: () => void;
360 isModelPickerOpen: () => boolean;383 isModelPickerOpen: () => boolean;
...@@ -799,6 +822,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -799,6 +822,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
799 () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []),822 () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []),
800 [activeThreadActivities],823 [activeThreadActivities],
801 );824 );
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
803 // ------------------------------------------------------------------836 // ------------------------------------------------------------------
804 // Composer-local state837 // Composer-local state
...@@ -1856,6 +1889,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1856,6 +1889,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1856 focusAt: (cursor: number) => {1889 focusAt: (cursor: number) => {
1857 composerEditorRef.current?.focusAt(cursor);1890 composerEditorRef.current?.focusAt(cursor);
1858 },1891 },
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 },
1859 openModelPicker: () => {1905 openModelPicker: () => {
1860 setIsComposerModelPickerOpen(true);1906 setIsComposerModelPickerOpen(true);
1861 },1907 },
...@@ -1940,6 +1986,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1940,6 +1986,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1940 promptRef,1986 promptRef,
1941 composerImagesRef,1987 composerImagesRef,
1942 composerTerminalContextsRef,1988 composerTerminalContextsRef,
1989 isConnecting,
1990 isComposerApprovalState,
1991 pendingUserInputs.length,
1992 environmentUnavailable,
1993 activePendingProgress,
1994 applyPromptReplacement,
1943 isComposerModelPickerOpen,1995 isComposerModelPickerOpen,
1944 readComposerSnapshot,1996 readComposerSnapshot,
1945 selectedModel,1997 selectedModel,
...@@ -2153,7 +2205,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2153,7 +2205,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2153 )}2205 )}
2154 >2206 >
2155 {composerMenuOpen && !isComposerApprovalState && (2207 {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">
2157 <ComposerCommandMenu2209 <ComposerCommandMenu
2158 items={composerMenuItems}2210 items={composerMenuItems}
2159 resolvedTheme={resolvedTheme}2211 resolvedTheme={resolvedTheme}
...@@ -2325,6 +2377,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2325,6 +2377,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2325 data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"}2377 data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"}
2326 className={cn(2378 className={cn(
2327 "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",2379 "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",
2328 isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",2381 isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",
2329 showMobilePendingAnswerActions && "hidden sm:flex",2382 showMobilePendingAnswerActions && "hidden sm:flex",
2330 )}2383 )}
...@@ -2400,6 +2453,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2400,6 +2453,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2400 <ComposerFooterPrimaryActions2453 <ComposerFooterPrimaryActions
2401 compact={isComposerPrimaryActionsCompact}2454 compact={isComposerPrimaryActionsCompact}
2402 activeContextWindow={activeContextWindow}2455 activeContextWindow={activeContextWindow}
2456 activeThreadProviderDisplayName={activeThreadProviderDisplayName}
2403 pendingAction={pendingPrimaryAction}2457 pendingAction={pendingPrimaryAction}
2404 isRunning={phase === "running"}2458 isRunning={phase === "running"}
2405 showPlanFollowUpPrompt={pendingUserInputs.length === 0 && showPlanFollowUpPrompt}2459 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: {...@@ -141,34 +141,35 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
141 >141 >
142 <div142 <div
143 ref={listRef}143 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"
145 >145 >
146 <CommandList className="max-h-72">146 {props.items.length > 0 ? (
147 {groups.map((group, groupIndex) => (147 <CommandList className="max-h-72">
148 <div key={group.id}>148 {groups.map((group, groupIndex) => (
149 {groupIndex > 0 ? <CommandSeparator className="my-0.5" /> : null}149 <div key={group.id}>
150 <CommandGroup>150 {groupIndex > 0 ? <CommandSeparator className="my-0.5" /> : null}
151 {group.label ? (151 <CommandGroup>
152 <CommandGroupLabel className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/55">152 {group.label ? (
153 {group.label}153 <CommandGroupLabel className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/55">
154 </CommandGroupLabel>154 {group.label}
155 ) : null}155 </CommandGroupLabel>
156 {group.items.map((item) => (156 ) : null}
157 <ComposerCommandMenuItem157 {group.items.map((item) => (
158 key={item.id}158 <ComposerCommandMenuItem
159 item={item}159 key={item.id}
160 resolvedTheme={props.resolvedTheme}160 item={item}
161 isActive={props.activeItemId === item.id}161 resolvedTheme={props.resolvedTheme}
162 onHighlight={props.onHighlightedItemChange}162 isActive={props.activeItemId === item.id}
163 onSelect={props.onSelect}163 onHighlight={props.onHighlightedItemChange}
164 />164 onSelect={props.onSelect}
165 ))}165 />
166 </CommandGroup>166 ))}
167 </div>167 </CommandGroup>
168 ))}168 </div>
169 </CommandList>169 ))}
170 {props.items.length === 0 ? (170 </CommandList>
171 <div className="px-3 py-2">171 ) : (
172 <div className="px-5 py-3.5">
172 {props.triggerKind === "skill" ? (173 {props.triggerKind === "skill" ? (
173 <CommandGroup>174 <CommandGroup>
174 <CommandGroupLabel className="px-0 pt-0 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/55">175 <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: {...@@ -192,7 +193,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
192 </p>193 </p>
193 )}194 )}
194 </div>195 </div>
195 ) : null}196 )}
196 </div>197 </div>
197 </Command>198 </Command>
198 );199 );
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+84-44
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { type ApprovalRequestId } from "@t3tools/contracts";1import { type ApprovalRequestId } from "@t3tools/contracts";
2import { memo, useEffect, useEffectEvent, useRef } from "react";2import { memo, useEffect, useEffectEvent, useRef, useState } from "react";
3import { type PendingUserInput } from "../../session-logic";3import { type PendingUserInput } from "../../session-logic";
4import {4import {
5 derivePendingUserInputProgress,5 derivePendingUserInputProgress,
...@@ -61,11 +61,36 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -61,11 +61,36 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
61 const activeQuestion = progress.activeQuestion;61 const activeQuestion = progress.activeQuestion;
62 const autoAdvanceTimerRef = useRef<number | null>(null);62 const autoAdvanceTimerRef = useRef<number | null>(null);
63 const onAdvanceRef = useRef(onAdvance);63 const onAdvanceRef = useRef(onAdvance);
64 const [optimisticSingleSelect, setOptimisticSingleSelect] = useState<{
65 questionId: string;
66 optionLabel: string;
67 } | null>(null);
6468
65 useEffect(() => {69 useEffect(() => {
66 onAdvanceRef.current = onAdvance;70 onAdvanceRef.current = onAdvance;
67 }, [onAdvance]);71 }, [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
69 // Clear auto-advance timer on unmount94 // Clear auto-advance timer on unmount
70 useEffect(() => {95 useEffect(() => {
71 return () => {96 return () => {
...@@ -76,10 +101,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -76,10 +101,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
76 }, []);101 }, []);
77102
78 const handleOptionSelection = useEffectEvent((questionId: string, optionLabel: string) => {103 const handleOptionSelection = useEffectEvent((questionId: string, optionLabel: string) => {
79 onToggleOption(questionId, optionLabel);
80 if (activeQuestion?.multiSelect) {104 if (activeQuestion?.multiSelect) {
105 onToggleOption(questionId, optionLabel);
81 return;106 return;
82 }107 }
108 setOptimisticSingleSelect({ questionId, optionLabel });
109 onToggleOption(questionId, optionLabel);
83 if (autoAdvanceTimerRef.current !== null) {110 if (autoAdvanceTimerRef.current !== null) {
84 window.clearTimeout(autoAdvanceTimerRef.current);111 window.clearTimeout(autoAdvanceTimerRef.current);
85 }112 }
...@@ -123,64 +150,77 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -123,64 +150,77 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
123 return null;150 return null;
124 }151 }
125152
153 const customAnswerActive = progress.customAnswer.trim().length > 0;
154
126 return (155 return (
127 <div className="px-4 py-3 sm:px-5">156 <div className="px-4 py-3 sm:px-5">
128 <div className="flex items-center gap-3">157 <div className="mb-2 flex items-center gap-3">
129 <div className="flex items-center gap-2">158 <span className="text-[11px] font-semibold tracking-widest text-muted-foreground/55 uppercase">
130 {prompt.questions.length > 1 ? (159 {activeQuestion.header}
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">160 </span>
132 {questionIndex + 1}/{prompt.questions.length}161 {prompt.questions.length > 1 ? (
133 </span>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">
134 ) : null}163 {questionIndex + 1}/{prompt.questions.length}
135 <span className="text-[11px] font-semibold tracking-widest text-muted-foreground/50 uppercase">
136 {activeQuestion.header}
137 </span>164 </span>
138 </div>165 ) : null}
139 </div>166 </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>
141 {activeQuestion.multiSelect ? (168 {activeQuestion.multiSelect ? (
142 <p className="mt-1 text-xs text-muted-foreground/65">Select one or more options.</p>169 <p className="mt-1 text-xs text-muted-foreground/65">Select one or more options.</p>
143 ) : null}170 ) : null}
144 <div className="mt-3 space-y-1">171 <div className="mt-3 space-y-1.5">
145 {activeQuestion.options.map((option, index) => {172 {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));
147 const shortcutKey = index < 9 ? index + 1 : null;179 const shortcutKey = index < 9 ? index + 1 : null;
148 return (180 const className = cn(
149 <button181 "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",
150 key={`${activeQuestion.id}:${option.label}`}182 isSelected
151 type="button"183 ? "border-primary/30 bg-primary/8 text-foreground"
152 disabled={isResponding}184 : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34",
153 onClick={() => handleOptionSelection(activeQuestion.id, option.label)}185 isResponding && "opacity-50 cursor-not-allowed",
154 className={cn(186 !isResponding && "cursor-pointer",
155 "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left transition-all duration-150",187 );
156 isSelected188 const content = (
157 ? "border-blue-500/40 bg-blue-500/8 text-foreground"189 <>
158 : "border-transparent bg-muted/20 text-foreground/80 hover:bg-muted/40 hover:border-border/40",190 <div className="min-w-0 flex-1 flex flex-col gap-0.5">
159 isResponding && "opacity-50 cursor-not-allowed",191 <span className="text-sm font-medium">{option.label}</span>
160 )}192 {option.description && option.description !== option.label ? (
161 >193 <span className="text-xs text-muted-foreground/50">{option.description}</span>
162 {shortcutKey !== null ? (194 ) : null}
195 </div>
196 {isSelected ? (
197 <CheckIcon className="size-3.5 shrink-0 text-primary" />
198 ) : shortcutKey !== null ? (
163 <kbd199 <kbd
164 className={cn(200 className={cn(
165 "flex size-5 shrink-0 items-center justify-center rounded text-[11px] font-medium tabular-nums transition-colors duration-150",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",
166 isSelected202 "bg-background/35 text-muted-foreground/70 group-hover:border-border/70 group-hover:text-muted-foreground",
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",
169 )}203 )}
170 >204 >
171 {shortcutKey}205 {shortcutKey}
172 </kbd>206 </kbd>
173 ) : null}207 ) : null}
174 <div className="min-w-0 flex-1">208 </>
175 <span className="text-sm font-medium">{option.label}</span>209 );
176 {option.description && option.description !== option.label ? (210 return (
177 <span className="ml-2 text-xs text-muted-foreground/50">211 <div
178 {option.description}212 key={`${activeQuestion.id}:${option.label}`}
179 </span>213 role="button"
180 ) : null}214 tabIndex={isResponding ? -1 : 0}
181 </div>215 aria-disabled={isResponding}
182 {isSelected ? <CheckIcon className="size-3.5 shrink-0 text-blue-400" /> : null}216 onClick={() => {
183 </button>217 if (isResponding) return;
218 handleOptionSelection(activeQuestion.id, option.label);
219 }}
220 className={className}
221 >
222 {content}
223 </div>
184 );224 );
185 })}225 })}
186 </div>226 </div>
apps/web/src/components/chat/ComposerPrimaryActions.tsx+4-20
...@@ -3,6 +3,7 @@ import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react";...@@ -3,6 +3,7 @@ import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react";
3import { cn } from "~/lib/utils";3import { cn } from "~/lib/utils";
4import { Button } from "../ui/button";4import { Button } from "../ui/button";
5import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";5import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
6import { Spinner } from "../ui/spinner";
67
7interface PendingActionState {8interface PendingActionState {
8 questionIndex: number;9 questionIndex: number;
...@@ -126,7 +127,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -126,7 +127,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
126 return (127 return (
127 <button128 <button
128 type="button"129 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"
130 {...pointerFocusProps}131 {...pointerFocusProps}
131 onClick={onInterrupt}132 onClick={onInterrupt}
132 aria-label="Stop generation"133 aria-label="Stop generation"
...@@ -195,7 +196,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -195,7 +196,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
195 return (196 return (
196 <button197 <button
197 type="submit"198 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"
199 {...pointerFocusProps}200 {...pointerFocusProps}
200 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || !hasSendableContent}201 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || !hasSendableContent}
201 aria-label={202 aria-label={
...@@ -211,24 +212,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -211,24 +212,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
211 }212 }
212 >213 >
213 {isConnecting || isSendBusy ? (214 {isConnecting || isSendBusy ? (
214 <svg215 <Spinner className="size-3.5" aria-hidden="true" />
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>
232 ) : (216 ) : (
233 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">217 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
234 <path218 <path
apps/web/src/components/chat/ContextWindowMeter.tsx+58-40
...@@ -12,13 +12,20 @@ function formatPercentage(value: number | null): string | null {...@@ -12,13 +12,20 @@ function formatPercentage(value: number | null): string | null {
12 return `${Math.round(value)}%`;12 return `${Math.round(value)}%`;
13}13}
1414
15export function ContextWindowMeter(props: { usage: ContextWindowSnapshot }) {15export function ContextWindowMeter(props: {
16 const { usage } = props;16 usage: ContextWindowSnapshot;
17 providerDisplayName?: string | null;
18}) {
19 const { usage, providerDisplayName } = props;
17 const usedPercentage = formatPercentage(usage.usedPercentage);20 const usedPercentage = formatPercentage(usage.usedPercentage);
18 const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0));21 const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0));
19 const radius = 9.75;22 const radius = 9.75;
20 const circumference = 2 * Math.PI * radius;23 const circumference = 2 * Math.PI * radius;
21 const dashOffset = circumference - (normalizedPercentage / 100) * circumference;24 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
23 return (30 return (
24 <Popover>31 <Popover>
...@@ -29,17 +36,21 @@ export function ContextWindowMeter(props: { usage: ContextWindowSnapshot }) {...@@ -29,17 +36,21 @@ export function ContextWindowMeter(props: { usage: ContextWindowSnapshot }) {
29 render={36 render={
30 <button37 <button
31 type="button"38 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 )}
33 aria-label={44 aria-label={
34 usage.maxTokens !== null && usedPercentage45 usage.maxTokens !== null && usedPercentage
35 ? `Context window ${usedPercentage} used`46 ? `Context window ${usedPercentage} used`
36 : `Context window ${formatContextWindowTokens(usage.usedTokens)} tokens used`47 : `Context window ${formatContextWindowTokens(usage.usedTokens)} tokens used`
37 }48 }
38 >49 >
39 <span className="relative flex h-6 w-6 items-center justify-center">50 <span className="relative flex size-4 items-center justify-center">
40 <svg51 <svg
41 viewBox="0 0 24 24"52 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"
43 aria-hidden="true"54 aria-hidden="true"
44 >55 >
45 <circle56 <circle
...@@ -47,7 +58,7 @@ export function ContextWindowMeter(props: { usage: ContextWindowSnapshot }) {...@@ -47,7 +58,7 @@ export function ContextWindowMeter(props: { usage: ContextWindowSnapshot }) {
47 cy="12"58 cy="12"
48 r={radius}59 r={radius}
49 fill="none"60 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)"
51 strokeWidth="3"62 strokeWidth="3"
52 />63 />
53 <circle64 <circle
...@@ -55,7 +66,7 @@ export function ContextWindowMeter(props: { usage: ContextWindowSnapshot }) {...@@ -55,7 +66,7 @@ export function ContextWindowMeter(props: { usage: ContextWindowSnapshot }) {
55 cy="12"66 cy="12"
56 r={radius}67 r={radius}
57 fill="none"68 fill="none"
58 stroke="var(--color-muted-foreground)"69 stroke={usageColor}
59 strokeWidth="3"70 strokeWidth="3"
60 strokeLinecap="round"71 strokeLinecap="round"
61 strokeDasharray={circumference}72 strokeDasharray={circumference}
...@@ -63,48 +74,55 @@ export function ContextWindowMeter(props: { usage: ContextWindowSnapshot }) {...@@ -63,48 +74,55 @@ export function ContextWindowMeter(props: { usage: ContextWindowSnapshot }) {
63 className="transition-[stroke-dashoffset] duration-500 ease-out motion-reduce:transition-none"74 className="transition-[stroke-dashoffset] duration-500 ease-out motion-reduce:transition-none"
64 />75 />
65 </svg>76 </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>
76 </span>77 </span>
77 </button>78 </button>
78 }79 }
79 />80 />
80 <PopoverPopup tooltipStyle side="top" align="end" className="w-max max-w-none px-3 py-2">81 <PopoverPopup tooltipStyle side="top" align="end" className="w-64 max-w-none p-0">
81 <div className="space-y-1.5 leading-tight">82 <div className="flex flex-col gap-2 p-3">
82 <div className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">83 <div className="flex items-center justify-between gap-3">
83 Context window84 <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 )}
84 </div>99 </div>
85 {usage.maxTokens !== null && usedPercentage ? (100 {usage.maxTokens !== null ? (
86 <div className="whitespace-nowrap text-xs font-medium text-foreground">101 <div
87 <span>{usedPercentage}</span>102 className="h-1.5 w-full overflow-hidden rounded-full bg-muted/60"
88 <span className="mx-1">⋅</span>103 role="progressbar"
89 <span>{formatContextWindowTokens(usage.usedTokens)}</span>104 aria-valuemin={0}
90 <span>/</span>105 aria-valuemax={100}
91 <span>{formatContextWindowTokens(usage.maxTokens ?? null)} context used</span>106 aria-valuenow={Math.round(normalizedPercentage)}
92 </div>107 aria-label="Context window usage"
93 ) : (108 >
94 <div className="text-sm text-foreground">109 <div
95 {formatContextWindowTokens(usage.usedTokens)} tokens used so far110 className="h-full rounded-full transition-[width,background-color] duration-500 ease-out motion-reduce:transition-none"
111 style={{ width: `${normalizedPercentage}%`, backgroundColor: usageColor }}
112 />
96 </div>113 </div>
97 )}114 ) : null}
98 {(usage.totalProcessedTokens ?? null) !== null &&115 {showTotalProcessed ? (
99 (usage.totalProcessedTokens ?? 0) > usage.usedTokens ? (116 <div className="flex items-center justify-between gap-3 text-[11px] leading-4">
100 <div className="text-xs text-muted-foreground">117 <span className="text-muted-foreground/60">Total processed</span>
101 Total processed: {formatContextWindowTokens(usage.totalProcessedTokens ?? null)}{" "}118 <span className="font-medium tabular-nums text-muted-foreground/80">
102 tokens119 {formatContextWindowTokens(totalProcessedTokens)}
120 </span>
103 </div>121 </div>
104 ) : null}122 ) : null}
105 {usage.compactsAutomatically ? (123 {usage.compactsAutomatically ? (
106 <div className="text-xs text-muted-foreground">124 <div className="mt-1 text-pretty text-[11px] font-medium text-muted-foreground/70">
107 Automatically compacts its context when needed.125 {providerDisplayName ?? "It"} automatically compacts its context when needed.
108 </div>126 </div>
109 ) : null}127 ) : null}
110 </div>128 </div>
apps/web/src/components/chat/composerProviderState.test.tsx+1-1
...@@ -56,7 +56,7 @@ function selections(...@@ -56,7 +56,7 @@ function selections(
5656
57const ULTRATHINK_FRAME_CLASSES = {57const ULTRATHINK_FRAME_CLASSES = {
58 composerFrameClassName: "ultrathink-frame",58 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]",
60 modelPickerIconClassName: "ultrathink-chroma",60 modelPickerIconClassName: "ultrathink-chroma",
61} as const;61} as const;
6262
apps/web/src/components/chat/composerProviderState.tsx+1-1
...@@ -67,7 +67,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com...@@ -67,7 +67,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com
67 ...(ultrathinkActive67 ...(ultrathinkActive
68 ? {68 ? {
69 composerFrameClassName: "ultrathink-frame",69 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]",
71 modelPickerIconClassName: "ultrathink-chroma",71 modelPickerIconClassName: "ultrathink-chroma",
72 }72 }
73 : {}),73 : {}),
apps/web/src/index.css+1
...@@ -685,6 +685,7 @@ label:has(> select#reasoning-effort) select {...@@ -685,6 +685,7 @@ label:has(> select#reasoning-effort) select {
685 );685 );
686 background-image: var(--ultrathink-spectrum);686 background-image: var(--ultrathink-spectrum);
687 background-size: 220% 220%;687 background-size: 220% 220%;
688 filter: saturate(0.82) brightness(0.92);
688 animation: ultrathink-rainbow 10s linear infinite;689 animation: ultrathink-rainbow 10s linear infinite;
689}690}
690691
apps/web/src/lib/contextWindow.ts+22
...@@ -25,6 +25,28 @@ export type ContextWindowSnapshot = NullableContextWindowUsage & {...@@ -25,6 +25,28 @@ export type ContextWindowSnapshot = NullableContextWindowUsage & {
25 readonly updatedAt: string;25 readonly updatedAt: string;
26};26};
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
28export function deriveLatestContextWindowSnapshot(50export function deriveLatestContextWindowSnapshot(
29 activities: ReadonlyArray<OrchestrationThreadActivity>,51 activities: ReadonlyArray<OrchestrationThreadActivity>,
30): ContextWindowSnapshot | null {52): ContextWindowSnapshot | null {