| 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,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 | } |
| 1418 | 1418 | ||
| 1419 | function findButtonContainingText(text: string): HTMLButtonElement | null { | 1419 | function 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 | } |
| 1424 | 1426 | ||
| 1425 | async function waitForButtonContainingText(text: string): Promise<HTMLButtonElement> { | 1427 | async 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 | }); |
| 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 | 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"] = []; |
| 203 | const EMPTY_PROVIDERS: ServerProvider[] = []; | 203 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 204 | const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; | 204 | const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; |
| 205 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; | 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 | type EnvironmentUnavailableState = { | 235 | type 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 = { |
| 211 | 240 | ||
| 212 | type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">; | 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 | function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { | 262 | function 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 | }; |
| 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 | 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"; |
| 95 | import { proposedPlanTitle } from "../../proposedPlan"; | 96 | import { proposedPlanTitle } from "../../proposedPlan"; |
| 96 | import { getProviderInteractionModeToggle } from "../../providerModels"; | 97 | import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels"; |
| 97 | import { | 98 | import { |
| 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"; |
| 105 | import type { SessionPhase, Thread } from "../../types"; | 106 | import type { SessionPhase, Thread } from "../../types"; |
| 106 | import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; | 107 | import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; |
| 107 | import type { PendingApproval, PendingUserInput } from "../../session-logic"; | 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 | import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; | 113 | import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; |
| 110 | import { searchProviderSkills } from "../../providerSkillSearch"; | 114 | import { searchProviderSkills } from "../../providerSkillSearch"; |
| 111 | import { useMediaQuery } from "../../hooks/useMediaQuery"; | 115 | import { 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`; |
| 197 | 201 | ||
| 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; | ||
| 201 | 236 | ||
| 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} | ||
| 228 | 240 | ||
| 229 | <Tooltip> | 241 | <Tooltip> |
| 230 | <Select | 242 | <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> |
| 269 | 281 | ||
| 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.planSidebarOpen | 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 | : "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 |
| 302 | const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { | 318 | const 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( |
| 355 | export interface ChatComposerHandle { | 377 | export 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]); | ||
| 802 | 835 | ||
| 803 | // ------------------------------------------------------------------ | 836 | // ------------------------------------------------------------------ |
| 804 | // Composer-local state | 837 | // 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 | <ComposerCommandMenu | 2209 | <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 | <ComposerFooterPrimaryActions | 2453 | <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 | <div | 142 | <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 | <ComposerCommandMenuItem | 157 | {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 @@ |
| 1 | import { type ApprovalRequestId } from "@t3tools/contracts"; | 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 | import { type PendingUserInput } from "../../session-logic"; | 3 | import { type PendingUserInput } from "../../session-logic"; |
| 4 | import { | 4 | import { |
| 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); | ||
| 64 | 68 | ||
| 65 | useEffect(() => { | 69 | useEffect(() => { |
| 66 | onAdvanceRef.current = onAdvance; | 70 | onAdvanceRef.current = onAdvance; |
| 67 | }, [onAdvance]); | 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 | // Clear auto-advance timer on unmount | 94 | // 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 | }, []); |
| 77 | 102 | ||
| 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 | } |
| 125 | 152 | ||
| 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 | <button | 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", |
| 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 | isSelected | 188 | 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 | <kbd | 199 | <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 | isSelected | 202 | "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"; |
| 3 | import { cn } from "~/lib/utils"; | 3 | import { cn } from "~/lib/utils"; |
| 4 | import { Button } from "../ui/button"; | 4 | import { Button } from "../ui/button"; |
| 5 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; | 5 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; |
| 6 | import { Spinner } from "../ui/spinner"; | ||
| 6 | 7 | ||
| 7 | interface PendingActionState { | 8 | interface 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 | <button | 128 | <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 | <button | 197 | <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 | <svg | 215 | <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 | <path | 218 | <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 | } |
| 14 | 14 | ||
| 15 | export function ContextWindowMeter(props: { usage: ContextWindowSnapshot }) { | 15 | export 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)"; | ||
| 22 | 29 | ||
| 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 | <button | 37 | <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 && usedPercentage | 45 | 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 | <svg | 51 | <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 | <circle | 56 | <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 | <circle | 64 | <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 window | 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 | </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 far | 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 | </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 | tokens | 119 | {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( |
| 56 | 56 | ||
| 57 | const ULTRATHINK_FRAME_CLASSES = { | 57 | const 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; |
| 62 | 62 |
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 | ...(ultrathinkActive | 67 | ...(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 | } |
| 690 | 691 |
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 | }; |
| 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 | export function deriveLatestContextWindowSnapshot( | 50 | export function deriveLatestContextWindowSnapshot( |
| 29 | activities: ReadonlyArray<OrchestrationThreadActivity>, | 51 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 30 | ): ContextWindowSnapshot | null { | 52 | ): ContextWindowSnapshot | null { |