| author | |
| committer | |
| log | a4757c2656864cca1397eb50176a8e60fb514618 |
| tree | 2f707cde723d90b38e379a74475620b95a910582 |
| parent | 3efabdcd3a7a6f0ed56443197a65a3a474cddb6c |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 1416 | return waitForElement(() => findButtonByText(text), `Unable to find "${text}" button.`); |
| 1417 | 1417 | } |
| 1418 | 1418 | |
| 1419 | function findButtonContainingText(text: string): HTMLButtonElement | null { | |
| 1420 | return (Array.from(document.querySelectorAll("button")).find((button) => | |
| 1421 | button.textContent?.includes(text), | |
| 1422 | ) ?? null) as HTMLButtonElement | null; | |
| 1419 | function 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 | ); | |
| 1423 | 1425 | } |
| 1424 | 1426 | |
| 1425 | async function waitForButtonContainingText(text: string): Promise<HTMLButtonElement> { | |
| 1427 | async function waitForButtonContainingText(text: string): Promise<HTMLElement> { | |
| 1426 | 1428 | return waitForElement( |
| 1427 | 1429 | () => findButtonContainingText(text), |
| 1428 | 1430 | `Unable to find button containing "${text}".`, |
| ... | ... | @@ -3219,6 +3221,89 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3219 | 3221 | } |
| 3220 | 3222 | }); |
| 3221 | 3223 | |
| 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 | 3307 | it("uses the active draft route session when changing the base branch", async () => { |
| 3223 | 3308 | const staleDraftId = draftIdFromPath("/draft/draft-stale-branch-session"); |
| 3224 | 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 | 203 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 204 | 204 | const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; |
| 205 | 205 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; |
| 206 | const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ | |
| 207 | "input", | |
| 208 | "textarea", | |
| 209 | "select", | |
| 210 | '[contenteditable="true"]', | |
| 211 | '[contenteditable="plaintext-only"]', | |
| 212 | '[role="textbox"]', | |
| 213 | ].join(","); | |
| 214 | const 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(","); | |
| 226 | const 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 | ||
| 206 | 235 | type EnvironmentUnavailableState = { |
| 207 | 236 | readonly environmentId: EnvironmentId; |
| 208 | 237 | readonly label: string; |
| ... | ... | @@ -211,6 +240,25 @@ type EnvironmentUnavailableState = { |
| 211 | 240 | |
| 212 | 241 | type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">; |
| 213 | 242 | |
| 243 | function 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 | ||
| 249 | function 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 | ||
| 214 | 262 | function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { |
| 215 | 263 | return useStore( |
| 216 | 264 | useMemo(() => { |
| ... | ... | @@ -2702,6 +2750,18 @@ export default function ChatView(props: ChatViewProps) { |
| 2702 | 2750 | modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, |
| 2703 | 2751 | }; |
| 2704 | 2752 | |
| 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 | 2765 | const command = resolveShortcutCommand(event, keybindings, { |
| 2706 | 2766 | context: shortcutContext, |
| 2707 | 2767 | }); |
apps/web/src/components/chat/ChatComposer.tsx+87-33| ... | ... | @@ -86,6 +86,7 @@ import { |
| 86 | 86 | BotIcon, |
| 87 | 87 | CircleAlertIcon, |
| 88 | 88 | ListTodoIcon, |
| 89 | PencilRulerIcon, | |
| 89 | 90 | type LucideIcon, |
| 90 | 91 | LockIcon, |
| 91 | 92 | LockOpenIcon, |
| ... | ... | @@ -93,7 +94,7 @@ import { |
| 93 | 94 | XIcon, |
| 94 | 95 | } from "lucide-react"; |
| 95 | 96 | import { proposedPlanTitle } from "../../proposedPlan"; |
| 96 | import { getProviderInteractionModeToggle } from "../../providerModels"; | |
| 97 | import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels"; | |
| 97 | 98 | import { |
| 98 | 99 | deriveProviderInstanceEntries, |
| 99 | 100 | resolveProviderDriverKindForInstanceSelection, |
| ... | ... | @@ -105,7 +106,10 @@ import type { UnifiedSettings } from "@t3tools/contracts/settings"; |
| 105 | 106 | import type { SessionPhase, Thread } from "../../types"; |
| 106 | 107 | import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; |
| 107 | 108 | import type { PendingApproval, PendingUserInput } from "../../session-logic"; |
| 108 | import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; | |
| 109 | import { | |
| 110 | deriveLatestContextWindowSnapshot, | |
| 111 | formatProviderDisplayName, | |
| 112 | } from "../../lib/contextWindow"; | |
| 109 | 113 | import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; |
| 110 | 114 | import { searchProviderSkills } from "../../providerSkillSearch"; |
| 111 | 115 | import { useMediaQuery } from "../../hooks/useMediaQuery"; |
| ... | ... | @@ -195,36 +199,44 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 195 | 199 | ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` |
| 196 | 200 | : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`; |
| 197 | 201 | |
| 198 | return ( | |
| 202 | const interactionModeToggle = props.showInteractionModeToggle ? ( | |
| 199 | 203 | <> |
| 200 | 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; | |
| 201 | 236 | |
| 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" /> | |
| 228 | 240 | |
| 229 | 241 | <Tooltip> |
| 230 | 242 | <Select |
| ... | ... | @@ -267,6 +279,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 267 | 279 | <TooltipPopup side="top">{runtimeModeOption.description}</TooltipPopup> |
| 268 | 280 | </Tooltip> |
| 269 | 281 | |
| 282 | {interactionModeToggle} | |
| 283 | ||
| 270 | 284 | {props.showPlanToggle ? ( |
| 271 | 285 | <> |
| 272 | 286 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> |
| ... | ... | @@ -278,7 +292,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 278 | 292 | className={cn( |
| 279 | 293 | "shrink-0 whitespace-nowrap px-2 sm:px-3", |
| 280 | 294 | 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 | 296 | : "text-muted-foreground/70 hover:text-foreground/80", |
| 283 | 297 | )} |
| 284 | 298 | size="sm" |
| ... | ... | @@ -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 | 308 | <span className="sr-only sm:not-sr-only">{props.planSidebarLabel}</span> |
| 293 | 309 | </TooltipTrigger> |
| 294 | 310 | <TooltipPopup side="top">{planSidebarTooltip}</TooltipPopup> |
| ... | ... | @@ -302,6 +318,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 302 | 318 | const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { |
| 303 | 319 | compact: boolean; |
| 304 | 320 | activeContextWindow: ReturnType<typeof deriveLatestContextWindowSnapshot>; |
| 321 | activeThreadProviderDisplayName: string | null; | |
| 305 | 322 | isPreparingWorktree: boolean; |
| 306 | 323 | pendingAction: { |
| 307 | 324 | questionIndex: number; |
| ... | ... | @@ -324,7 +341,12 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( |
| 324 | 341 | }) { |
| 325 | 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 | 350 | {props.isPreparingWorktree ? ( |
| 329 | 351 | <span className="text-muted-foreground/70 text-xs">Preparing worktree...</span> |
| 330 | 352 | ) : null} |
| ... | ... | @@ -355,6 +377,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( |
| 355 | 377 | export interface ChatComposerHandle { |
| 356 | 378 | focusAtEnd: () => void; |
| 357 | 379 | focusAt: (cursor: number) => void; |
| 380 | insertTextAtEnd: (text: string) => boolean; | |
| 358 | 381 | openModelPicker: () => void; |
| 359 | 382 | toggleModelPicker: () => void; |
| 360 | 383 | isModelPickerOpen: () => boolean; |
| ... | ... | @@ -799,6 +822,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 799 | 822 | () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []), |
| 800 | 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]); | |
| 802 | 835 | |
| 803 | 836 | // ------------------------------------------------------------------ |
| 804 | 837 | // Composer-local state |
| ... | ... | @@ -1856,6 +1889,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1856 | 1889 | focusAt: (cursor: number) => { |
| 1857 | 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 | 1905 | openModelPicker: () => { |
| 1860 | 1906 | setIsComposerModelPickerOpen(true); |
| 1861 | 1907 | }, |
| ... | ... | @@ -1940,6 +1986,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1940 | 1986 | promptRef, |
| 1941 | 1987 | composerImagesRef, |
| 1942 | 1988 | composerTerminalContextsRef, |
| 1989 | isConnecting, | |
| 1990 | isComposerApprovalState, | |
| 1991 | pendingUserInputs.length, | |
| 1992 | environmentUnavailable, | |
| 1993 | activePendingProgress, | |
| 1994 | applyPromptReplacement, | |
| 1943 | 1995 | isComposerModelPickerOpen, |
| 1944 | 1996 | readComposerSnapshot, |
| 1945 | 1997 | selectedModel, |
| ... | ... | @@ -2153,7 +2205,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2153 | 2205 | )} |
| 2154 | 2206 | > |
| 2155 | 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 | 2209 | <ComposerCommandMenu |
| 2158 | 2210 | items={composerMenuItems} |
| 2159 | 2211 | resolvedTheme={resolvedTheme} |
| ... | ... | @@ -2325,6 +2377,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2325 | 2377 | data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"} |
| 2326 | 2378 | className={cn( |
| 2327 | 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 | 2381 | isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", |
| 2329 | 2382 | showMobilePendingAnswerActions && "hidden sm:flex", |
| 2330 | 2383 | )} |
| ... | ... | @@ -2400,6 +2453,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2400 | 2453 | <ComposerFooterPrimaryActions |
| 2401 | 2454 | compact={isComposerPrimaryActionsCompact} |
| 2402 | 2455 | activeContextWindow={activeContextWindow} |
| 2456 | activeThreadProviderDisplayName={activeThreadProviderDisplayName} | |
| 2403 | 2457 | pendingAction={pendingPrimaryAction} |
| 2404 | 2458 | isRunning={phase === "running"} |
| 2405 | 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 | 141 | > |
| 142 | 142 | <div |
| 143 | 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"> | |
| 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"> | |
| 172 | 173 | {props.triggerKind === "skill" ? ( |
| 173 | 174 | <CommandGroup> |
| 174 | 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 | 193 | </p> |
| 193 | 194 | )} |
| 194 | 195 | </div> |
| 195 | ) : null} | |
| 196 | )} | |
| 196 | 197 | </div> |
| 197 | 198 | </Command> |
| 198 | 199 | ); |
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+84-44| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { type ApprovalRequestId } from "@t3tools/contracts"; |
| 2 | import { memo, useEffect, useEffectEvent, useRef } from "react"; | |
| 2 | import { memo, useEffect, useEffectEvent, useRef, useState } from "react"; | |
| 3 | 3 | import { type PendingUserInput } from "../../session-logic"; |
| 4 | 4 | import { |
| 5 | 5 | derivePendingUserInputProgress, |
| ... | ... | @@ -61,11 +61,36 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 61 | 61 | const activeQuestion = progress.activeQuestion; |
| 62 | 62 | const autoAdvanceTimerRef = useRef<number | null>(null); |
| 63 | 63 | const onAdvanceRef = useRef(onAdvance); |
| 64 | const [optimisticSingleSelect, setOptimisticSingleSelect] = useState<{ | |
| 65 | questionId: string; | |
| 66 | optionLabel: string; | |
| 67 | } | null>(null); | |
| 64 | 68 | |
| 65 | 69 | useEffect(() => { |
| 66 | 70 | onAdvanceRef.current = onAdvance; |
| 67 | 71 | }, [onAdvance]); |
| 68 | 72 | |
| 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 | 94 | // Clear auto-advance timer on unmount |
| 70 | 95 | useEffect(() => { |
| 71 | 96 | return () => { |
| ... | ... | @@ -76,10 +101,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 76 | 101 | }, []); |
| 77 | 102 | |
| 78 | 103 | const handleOptionSelection = useEffectEvent((questionId: string, optionLabel: string) => { |
| 79 | onToggleOption(questionId, optionLabel); | |
| 80 | 104 | if (activeQuestion?.multiSelect) { |
| 105 | onToggleOption(questionId, optionLabel); | |
| 81 | 106 | return; |
| 82 | 107 | } |
| 108 | setOptimisticSingleSelect({ questionId, optionLabel }); | |
| 109 | onToggleOption(questionId, optionLabel); | |
| 83 | 110 | if (autoAdvanceTimerRef.current !== null) { |
| 84 | 111 | window.clearTimeout(autoAdvanceTimerRef.current); |
| 85 | 112 | } |
| ... | ... | @@ -123,64 +150,77 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 123 | 150 | return null; |
| 124 | 151 | } |
| 125 | 152 | |
| 153 | const customAnswerActive = progress.customAnswer.trim().length > 0; | |
| 154 | ||
| 126 | 155 | return ( |
| 127 | 156 | <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} | |
| 137 | 164 | </span> |
| 138 | </div> | |
| 165 | ) : null} | |
| 139 | 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 | 168 | {activeQuestion.multiSelect ? ( |
| 142 | 169 | <p className="mt-1 text-xs text-muted-foreground/65">Select one or more options.</p> |
| 143 | 170 | ) : null} |
| 144 | <div className="mt-3 space-y-1"> | |
| 171 | <div className="mt-3 space-y-1.5"> | |
| 145 | 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 | 179 | 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 ? ( | |
| 163 | 199 | <kbd |
| 164 | 200 | 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", | |
| 169 | 203 | )} |
| 170 | 204 | > |
| 171 | 205 | {shortcutKey} |
| 172 | 206 | </kbd> |
| 173 | 207 | ) : 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> | |
| 184 | 224 | ); |
| 185 | 225 | })} |
| 186 | 226 | </div> |
apps/web/src/components/chat/ComposerPrimaryActions.tsx+4-20| ... | ... | @@ -3,6 +3,7 @@ import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react"; |
| 3 | 3 | import { cn } from "~/lib/utils"; |
| 4 | 4 | import { Button } from "../ui/button"; |
| 5 | 5 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; |
| 6 | import { Spinner } from "../ui/spinner"; | |
| 6 | 7 | |
| 7 | 8 | interface PendingActionState { |
| 8 | 9 | questionIndex: number; |
| ... | ... | @@ -126,7 +127,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 126 | 127 | return ( |
| 127 | 128 | <button |
| 128 | 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 | 131 | {...pointerFocusProps} |
| 131 | 132 | onClick={onInterrupt} |
| 132 | 133 | aria-label="Stop generation" |
| ... | ... | @@ -195,7 +196,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 195 | 196 | return ( |
| 196 | 197 | <button |
| 197 | 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 | 200 | {...pointerFocusProps} |
| 200 | 201 | disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || !hasSendableContent} |
| 201 | 202 | aria-label={ |
| ... | ... | @@ -211,24 +212,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 211 | 212 | } |
| 212 | 213 | > |
| 213 | 214 | {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" /> | |
| 232 | 216 | ) : ( |
| 233 | 217 | <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true"> |
| 234 | 218 | <path |
apps/web/src/components/chat/ContextWindowMeter.tsx+58-40| ... | ... | @@ -12,13 +12,20 @@ function formatPercentage(value: number | null): string | null { |
| 12 | 12 | return `${Math.round(value)}%`; |
| 13 | 13 | } |
| 14 | 14 | |
| 15 | export function ContextWindowMeter(props: { usage: ContextWindowSnapshot }) { | |
| 16 | const { usage } = props; | |
| 15 | export function ContextWindowMeter(props: { | |
| 16 | usage: ContextWindowSnapshot; | |
| 17 | providerDisplayName?: string | null; | |
| 18 | }) { | |
| 19 | const { usage, providerDisplayName } = props; | |
| 17 | 20 | const usedPercentage = formatPercentage(usage.usedPercentage); |
| 18 | 21 | const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0)); |
| 19 | 22 | const radius = 9.75; |
| 20 | 23 | const circumference = 2 * Math.PI * radius; |
| 21 | 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)"; | |
| 22 | 29 | |
| 23 | 30 | return ( |
| 24 | 31 | <Popover> |
| ... | ... | @@ -29,17 +36,21 @@ export function ContextWindowMeter(props: { usage: ContextWindowSnapshot }) { |
| 29 | 36 | render={ |
| 30 | 37 | <button |
| 31 | 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 | 44 | aria-label={ |
| 34 | 45 | usage.maxTokens !== null && usedPercentage |
| 35 | 46 | ? `Context window ${usedPercentage} used` |
| 36 | 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 | 51 | <svg |
| 41 | 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 | 54 | aria-hidden="true" |
| 44 | 55 | > |
| 45 | 56 | <circle |
| ... | ... | @@ -47,7 +58,7 @@ export function ContextWindowMeter(props: { usage: ContextWindowSnapshot }) { |
| 47 | 58 | cy="12" |
| 48 | 59 | r={radius} |
| 49 | 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 | 62 | strokeWidth="3" |
| 52 | 63 | /> |
| 53 | 64 | <circle |
| ... | ... | @@ -55,7 +66,7 @@ export function ContextWindowMeter(props: { usage: ContextWindowSnapshot }) { |
| 55 | 66 | cy="12" |
| 56 | 67 | r={radius} |
| 57 | 68 | fill="none" |
| 58 | stroke="var(--color-muted-foreground)" | |
| 69 | stroke={usageColor} | |
| 59 | 70 | strokeWidth="3" |
| 60 | 71 | strokeLinecap="round" |
| 61 | 72 | strokeDasharray={circumference} |
| ... | ... | @@ -63,48 +74,55 @@ export function ContextWindowMeter(props: { usage: ContextWindowSnapshot }) { |
| 63 | 74 | className="transition-[stroke-dashoffset] duration-500 ease-out motion-reduce:transition-none" |
| 64 | 75 | /> |
| 65 | 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 | 77 | </span> |
| 77 | 78 | </button> |
| 78 | 79 | } |
| 79 | 80 | /> |
| 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 | )} | |
| 84 | 99 | </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 | /> | |
| 96 | 113 | </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> | |
| 103 | 121 | </div> |
| 104 | 122 | ) : null} |
| 105 | 123 | {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. | |
| 108 | 126 | </div> |
| 109 | 127 | ) : null} |
| 110 | 128 | </div> |
apps/web/src/components/chat/composerProviderState.test.tsx+1-1| ... | ... | @@ -56,7 +56,7 @@ function selections( |
| 56 | 56 | |
| 57 | 57 | const ULTRATHINK_FRAME_CLASSES = { |
| 58 | 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 | 60 | modelPickerIconClassName: "ultrathink-chroma", |
| 61 | 61 | } as const; |
| 62 | 62 |
apps/web/src/components/chat/composerProviderState.tsx+1-1| ... | ... | @@ -67,7 +67,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com |
| 67 | 67 | ...(ultrathinkActive |
| 68 | 68 | ? { |
| 69 | 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 | 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 | 685 | ); |
| 686 | 686 | background-image: var(--ultrathink-spectrum); |
| 687 | 687 | background-size: 220% 220%; |
| 688 | filter: saturate(0.82) brightness(0.92); | |
| 688 | 689 | animation: ultrathink-rainbow 10s linear infinite; |
| 689 | 690 | } |
| 690 | 691 |
apps/web/src/lib/contextWindow.ts+22| ... | ... | @@ -25,6 +25,28 @@ export type ContextWindowSnapshot = NullableContextWindowUsage & { |
| 25 | 25 | readonly updatedAt: string; |
| 26 | 26 | }; |
| 27 | 27 | |
| 28 | /** Map a provider driver kind to a user-facing display name. */ | |
| 29 | export 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 | ||
| 28 | 50 | export function deriveLatestContextWindowSnapshot( |
| 29 | 51 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 30 | 52 | ): ContextWindowSnapshot | null { |