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 (
+
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,
});