diff --git a/apps/web/src/components/ComposerPromptEditor.browser.tsx b/apps/web/src/components/ComposerPromptEditor.browser.tsx new file mode 100644 index 000000000000..094c7d95b47d --- /dev/null +++ b/apps/web/src/components/ComposerPromptEditor.browser.tsx @@ -0,0 +1,63 @@ +import { page } from "vitest/browser"; +import { expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; +import { useState } from "react"; + +import { ComposerPromptEditor } from "./ComposerPromptEditor"; + +it("does not commit or interrupt controlled input when Enter resolves an IME composition", async () => { + const onCommandKeyDown = vi.fn(() => true); + const onParentKeyDown = vi.fn(); + + function Harness() { + const [value, setValue] = useState(""); + const [cursor, setCursor] = useState(0); + + return ( +
+ { + setValue(nextValue); + setCursor(nextCursor); + }} + onCommandKeyDown={onCommandKeyDown} + onPaste={vi.fn()} + /> +
+ ); + } + + const screen = await render(); + + try { + const editor = page.getByTestId("composer-editor"); + const element = await editor.element(); + element.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); + await editor.fill("に"); + + const enter = new KeyboardEvent("keydown", { + key: "Enter", + code: "Enter", + bubbles: true, + cancelable: true, + isComposing: true, + }); + element.dispatchEvent(enter); + + expect(onCommandKeyDown).not.toHaveBeenCalled(); + expect(onParentKeyDown).not.toHaveBeenCalled(); + await expect.element(editor).toHaveTextContent("に"); + + element.dispatchEvent(new CompositionEvent("compositionend", { data: "に", bubbles: true })); + await expect.element(editor).toHaveTextContent("に"); + } finally { + await screen.unmount(); + } +}); diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 1b7441840a56..77e4e9e9aa0c 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -925,6 +925,10 @@ function ComposerCommandKeyPlugin(props: { if (!props.onCommandKeyDown || !event) { return false; } + if (key === "Enter" && (event.isComposing || event.keyCode === 229)) { + event.stopPropagation(); + return true; + } const handled = props.onCommandKeyDown(key, event); if (handled) { event.preventDefault(); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4153ec069d55..cf006d41943c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -712,7 +712,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP title={terminalStatus.label} className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`} > - + )}
diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 3374d095e03a..dd478dc17979 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -99,7 +99,7 @@ export function ThreadStatusLabel({ > {status.label} @@ -115,7 +115,7 @@ export function ThreadStatusLabel({ > {status.label} @@ -219,7 +219,9 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma title={terminalStatus.label} className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`} > - + ) : null} {isRemoteThread ? ( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 4e8959157235..e0f7c6248a3f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -76,6 +76,7 @@ import { QueuedMessagesPanel } from "./QueuedMessagesPanel"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { searchSlashCommandItems } from "./composerSlashCommandSearch"; import { + getComposerPromptInjectionState, getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, @@ -734,18 +735,22 @@ export const ChatComposer = memo( [selectedProviderEntry], ); + const composerPromptInjectionState = useMemo( + () => getComposerPromptInjectionState(prompt), + [prompt], + ); const composerProviderState = useMemo( () => getComposerProviderState({ provider: selectedProvider, model: selectedModel, models: selectedProviderModels, - prompt, + promptInjectionState: composerPromptInjectionState, modelOptions: composerModelOptions?.[selectedInstanceId], }), [ composerModelOptions, - prompt, + composerPromptInjectionState, selectedInstanceId, selectedModel, selectedProvider, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 30a8799f6991..bd4a62c962de 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -552,9 +552,9 @@ function TimelineRowContent(props: { row: TimelineRow }) {
- - - + + + {row.createdAt ? ( diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index 2414783eefdb..f0f37a2142ed 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -8,6 +8,7 @@ import { PROVIDER_ICON_BY_PROVIDER, } from "./providerIconUtils"; import { ComboboxItem } from "../ui/combobox"; +import { Button } from "../ui/button"; import { Kbd } from "../ui/kbd"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; @@ -53,8 +54,10 @@ export const ModelListRow = memo(function ModelListRow(props: { { event.stopPropagation(); props.onToggleFavorite(); @@ -62,13 +65,12 @@ export const ModelListRow = memo(function ModelListRow(props: { onKeyDown={(event) => { event.stopPropagation(); }} - type="button" aria-label={props.isFavorite ? "Remove from favorites" : "Add to favorites"} > - + } /> diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 4a2860ee4690..bb0c39625af9 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -120,7 +120,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { > diff --git a/apps/web/src/components/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx index fbd1620a0bde..86d1d457ba84 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.tsx @@ -12,7 +12,7 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({ }) { if (!error) return null; return ( -
+
diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index d9e35c2bd530..89033578882d 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -6,6 +6,7 @@ import { type ServerProviderModel, } from "@t3tools/contracts"; import { + getComposerPromptInjectionState, getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, @@ -61,6 +62,13 @@ const ULTRATHINK_FRAME_CLASSES = { } as const; describe("getComposerProviderState", () => { + it("derives a stable prompt injection state for ordinary prompt edits", () => { + expect(getComposerPromptInjectionState("Investigate this failure")).toBe("none"); + expect(getComposerPromptInjectionState("Ultrathink:\nInvestigate this failure")).toBe( + "ultrathink", + ); + }); + it("returns descriptor defaults when no selections are provided", () => { const state = getComposerProviderState({ provider: PROVIDER, @@ -71,7 +79,6 @@ describe("getComposerProviderState", () => { { id: "high", label: "High", isDefault: true }, ]), ]), - prompt: "", modelOptions: undefined, }); @@ -93,7 +100,6 @@ describe("getComposerProviderState", () => { ]), booleanDescriptor("fastMode"), ]), - prompt: "", modelOptions: selections(["effort", "low"], ["fastMode", true]), }); @@ -112,7 +118,6 @@ describe("getComposerProviderState", () => { selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), booleanDescriptor("fastMode"), ]), - prompt: "", modelOptions: selections(["effort", "high"], ["fastMode", false]), }); @@ -126,7 +131,6 @@ describe("getComposerProviderState", () => { provider: PROVIDER, model: MODEL, models: modelWith([booleanDescriptor("thinking")]), - prompt: "", modelOptions: selections(["effort", "max"], ["thinking", false]), }); @@ -152,7 +156,6 @@ describe("getComposerProviderState", () => { { id: "plan", label: "Plan" }, ]), ]), - prompt: "", modelOptions: selections(["agent", "plan"]), }); @@ -167,7 +170,6 @@ describe("getComposerProviderState", () => { provider: PROVIDER, model: MODEL, models: modelWith([]), - prompt: "", modelOptions: selections(["anything", "value"]), }); @@ -193,7 +195,9 @@ describe("getComposerProviderState", () => { ["ultrathink"], ), ]), - prompt: "Ultrathink:\nInvestigate this failure", + promptInjectionState: getComposerPromptInjectionState( + "Ultrathink:\nInvestigate this failure", + ), modelOptions: selections(["effort", "medium"]), }); @@ -212,7 +216,9 @@ describe("getComposerProviderState", () => { models: modelWith([ selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), ]), - prompt: "Ultrathink:\nInvestigate this failure", + promptInjectionState: getComposerPromptInjectionState( + "Ultrathink:\nInvestigate this failure", + ), modelOptions: undefined, }); diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index 77269d0bd90f..3dc415cea925 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -21,10 +21,12 @@ export type ComposerProviderStateInput = { provider: ProviderDriverKind; model: string; models: ReadonlyArray; - prompt: string; + promptInjectionState?: ComposerPromptInjectionState; modelOptions: ReadonlyArray | null | undefined; }; +export type ComposerPromptInjectionState = "none" | "ultrathink"; + export type ComposerProviderState = { provider: ProviderDriverKind; promptEffort: string | null; @@ -46,8 +48,12 @@ type TraitsRenderInput = { onPromptChange: (prompt: string) => void; }; +export function getComposerPromptInjectionState(prompt: string): ComposerPromptInjectionState { + return isClaudeUltrathinkPrompt(prompt) ? "ultrathink" : "none"; +} + export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { - const { provider, model, models, prompt, modelOptions } = input; + const { provider, model, models, modelOptions, promptInjectionState = "none" } = input; const caps = getProviderModelCapabilities(models, model, provider); const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions }); const primarySelectDescriptor = descriptors.find( @@ -58,7 +64,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com const promptEffort = typeof primaryValue === "string" ? primaryValue : null; const ultrathinkActive = (primarySelectDescriptor?.promptInjectedValues?.length ?? 0) > 0 && - isClaudeUltrathinkPrompt(prompt); + promptInjectionState === "ultrathink"; return { provider, diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 43805faf10f3..6de96299e594 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -99,7 +99,7 @@ function ConnectionStatusDot({ {pingClassName ? ( diff --git a/apps/web/src/index.css b/apps/web/src/index.css index ac398a89b8a9..6111fb5a7bfc 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -6,6 +6,8 @@ @theme inline { --animate-skeleton: skeleton 2s -1s infinite linear; + --animate-status-pulse: status-pulse 2s infinite; + --animate-status-ping: status-ping 2s infinite; --font-mono: var(--app-font-mono-stack); --font-sans: var(--app-font-sans-stack); --color-warning-foreground: var(--warning-foreground); @@ -45,6 +47,33 @@ background-position: -200% 0; } } + @keyframes status-pulse { + 0%, + 40% { + opacity: 1; + animation-timing-function: steps(6); + } + 50%, + 90% { + opacity: 0.5; + animation-timing-function: steps(6); + } + 100% { + opacity: 1; + } + } + @keyframes status-ping { + 0% { + opacity: 0.9; + scale: 0.75; + animation-timing-function: steps(8); + } + 40%, + 100% { + opacity: 0; + scale: 2; + } + } } @layer base { @@ -326,13 +355,8 @@ body { padding-top: max(env(safe-area-inset-top), 0px); } -body::after { - content: ""; - position: fixed; - inset: 0; - pointer-events: none; - opacity: 0.035; - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +body { + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E"); background-repeat: repeat; background-size: 256px 256px; } diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 8ba0c8fa5d9b..f26c1dbe69e0 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -299,7 +299,6 @@ export function resolveAppModelSelectionState( provider, model, models: entry.models, - prompt: "", modelOptions: selectedEntry ? selection.options : undefined, }); @@ -317,7 +316,6 @@ export function resolveAppModelSelectionState( provider, model, models: getProviderModels(providers, provider), - prompt: "", modelOptions: keptSelectedProvider ? selection.options : undefined, });