Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions apps/web/src/components/ComposerPromptEditor.browser.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div onKeyDown={onParentKeyDown}>
<ComposerPromptEditor
value={value}
cursor={cursor}
terminalContexts={[]}
skills={[]}
disabled={false}
placeholder="Prompt"
onRemoveTerminalContext={vi.fn()}
onChange={(nextValue, nextCursor) => {
setValue(nextValue);
setCursor(nextCursor);
}}
onCommandKeyDown={onCommandKeyDown}
onPaste={vi.fn()}
/>
</div>
);
}

const screen = await render(<Harness />);

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();
}
});
4 changes: 4 additions & 0 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -712,7 +712,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
title={terminalStatus.label}
className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
>
<TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} />
<TerminalIcon
className={`size-3 ${terminalStatus.pulse ? "animate-status-pulse" : ""}`}
/>
</span>
)}
<div
Expand Down Expand Up @@ -2404,7 +2406,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
<span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0">
<span
className={`size-[9px] rounded-full ${projectStatus.dotClass} ${
projectStatus.pulse ? "animate-pulse" : ""
projectStatus.pulse ? "animate-status-pulse" : ""
}`}
/>
</span>
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/components/ThreadStatusIndicators.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export function ThreadStatusLabel({
>
<span
className={`size-[9px] rounded-full ${status.dotClass} ${
status.pulse ? "animate-pulse" : ""
status.pulse ? "animate-status-pulse" : ""
}`}
/>
<span className="sr-only">{status.label}</span>
Expand All @@ -115,7 +115,7 @@ export function ThreadStatusLabel({
>
<span
className={`h-1.5 w-1.5 rounded-full ${status.dotClass} ${
status.pulse ? "animate-pulse" : ""
status.pulse ? "animate-status-pulse" : ""
}`}
/>
<span className="hidden md:inline">{status.label}</span>
Expand Down Expand Up @@ -219,7 +219,9 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma
title={terminalStatus.label}
className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
>
<TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} />
<TerminalIcon
className={`size-3 ${terminalStatus.pulse ? "animate-status-pulse" : ""}`}
/>
</span>
) : null}
{isRemoteThread ? (
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import { QueuedMessagesPanel } from "./QueuedMessagesPanel";
import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight";
import { searchSlashCommandItems } from "./composerSlashCommandSearch";
import {
getComposerPromptInjectionState,
getComposerProviderState,
renderProviderTraitsMenuContent,
renderProviderTraitsPicker,
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -552,9 +552,9 @@ function TimelineRowContent(props: { row: TimelineRow }) {
<div className="py-0.5 pl-1.5">
<div className="flex items-center gap-2 pt-1 text-[10px] text-muted-foreground/50">
<span className="inline-flex items-center gap-[3px]">
<span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
<span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />
<span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
<span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse" />
<span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:200ms]" />
<span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:400ms]" />
</span>
<span>
{row.createdAt ? (
Expand Down
10 changes: 6 additions & 4 deletions apps/web/src/components/chat/ModelListRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -53,22 +54,23 @@ export const ModelListRow = memo(function ModelListRow(props: {
<Tooltip>
<TooltipTrigger
render={
<button
className="mt-0.5 shrink-0 cursor-pointer opacity-40 transition-opacity group-hover:opacity-100"
<Button
size="icon-xs"
variant="ghost"
className="mt-0.5 shrink-0 opacity-40 transition-opacity group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
props.onToggleFavorite();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
type="button"
aria-label={props.isFavorite ? "Remove from favorites" : "Add to favorites"}
>
<StarIcon
className={cn("size-4", props.isFavorite && "fill-current text-yellow-500")}
/>
</button>
</Button>
}
/>
<TooltipPopup side="top" align="center">
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/ProviderModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
>
<span
className={cn(
"flex min-w-0 w-full box-border items-center gap-2 overflow-hidden",
"flex min-w-0 w-full box-border items-center gap-2",
props.compact ? "max-w-36 sm:pl-1" : undefined,
)}
>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/ThreadErrorBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({
}) {
if (!error) return null;
return (
<div className="pt-3 mx-auto max-w-3xl">
<div className="mx-auto w-fit max-w-[min(48rem,calc(100%-2rem))] pt-3">
<Alert variant="error">
<CircleAlertIcon />
<AlertDescription className="line-clamp-3" title={error}>
Expand Down
22 changes: 14 additions & 8 deletions apps/web/src/components/chat/composerProviderState.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type ServerProviderModel,
} from "@t3tools/contracts";
import {
getComposerPromptInjectionState,
getComposerProviderState,
renderProviderTraitsMenuContent,
renderProviderTraitsPicker,
Expand Down Expand Up @@ -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,
Expand All @@ -71,7 +79,6 @@ describe("getComposerProviderState", () => {
{ id: "high", label: "High", isDefault: true },
]),
]),
prompt: "",
modelOptions: undefined,
});

Expand All @@ -93,7 +100,6 @@ describe("getComposerProviderState", () => {
]),
booleanDescriptor("fastMode"),
]),
prompt: "",
modelOptions: selections(["effort", "low"], ["fastMode", true]),
});

Expand All @@ -112,7 +118,6 @@ describe("getComposerProviderState", () => {
selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]),
booleanDescriptor("fastMode"),
]),
prompt: "",
modelOptions: selections(["effort", "high"], ["fastMode", false]),
});

Expand All @@ -126,7 +131,6 @@ describe("getComposerProviderState", () => {
provider: PROVIDER,
model: MODEL,
models: modelWith([booleanDescriptor("thinking")]),
prompt: "",
modelOptions: selections(["effort", "max"], ["thinking", false]),
});

Expand All @@ -152,7 +156,6 @@ describe("getComposerProviderState", () => {
{ id: "plan", label: "Plan" },
]),
]),
prompt: "",
modelOptions: selections(["agent", "plan"]),
});

Expand All @@ -167,7 +170,6 @@ describe("getComposerProviderState", () => {
provider: PROVIDER,
model: MODEL,
models: modelWith([]),
prompt: "",
modelOptions: selections(["anything", "value"]),
});

Expand All @@ -193,7 +195,9 @@ describe("getComposerProviderState", () => {
["ultrathink"],
),
]),
prompt: "Ultrathink:\nInvestigate this failure",
promptInjectionState: getComposerPromptInjectionState(
"Ultrathink:\nInvestigate this failure",
),
modelOptions: selections(["effort", "medium"]),
});

Expand All @@ -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,
});

Expand Down
12 changes: 9 additions & 3 deletions apps/web/src/components/chat/composerProviderState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ export type ComposerProviderStateInput = {
provider: ProviderDriverKind;
model: string;
models: ReadonlyArray<ServerProviderModel>;
prompt: string;
promptInjectionState?: ComposerPromptInjectionState;
modelOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined;
};

export type ComposerPromptInjectionState = "none" | "ultrathink";

export type ComposerProviderState = {
provider: ProviderDriverKind;
promptEffort: string | null;
Expand All @@ -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(
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/settings/ConnectionsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ function ConnectionStatusDot({
{pingClassName ? (
<span
className={cn(
"absolute inline-flex h-full w-full animate-ping rounded-full",
"absolute inline-flex h-full w-full animate-status-ping rounded-full",
pingClassName,
)}
/>
Expand Down
Loading
Loading