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
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* @vitest-environment jsdom */

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, type RenderResult } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor, within, type RenderResult } from "@testing-library/react";
import type { ComponentProps } from "react";
import type { NormalizedLinearIssue } from "../../../shared/types";
import { AgentChatComposer } from "./AgentChatComposer";
Expand Down Expand Up @@ -317,6 +317,22 @@ describe("AgentChatComposer", () => {
expect(onSendSteerNow).toHaveBeenCalledTimes(1);
});

it("keeps Claude steering options compact and concise", () => {
renderComposer({
...CLAUDE_STEER_OVERRIDES,
onSendSteerNow: vi.fn(),
onSendSteerInterrupt: vi.fn(),
});

fireEvent.click(screen.getByRole("button", { name: "More send options" }));

const menu = screen.getByRole("menu", { name: "Send options" });
expect(menu.style.width).toBe("240px");
expect(menu.textContent).toContain("After the current tool step.");
expect(menu.textContent).toContain("When this turn finishes.");
expect(menu.textContent).toContain("Stop and redirect Claude now.");
});

it("disables the active-turn send actions when the draft is whitespace-only", () => {
const onSendSteerNow = vi.fn();
renderComposer({
Expand Down Expand Up @@ -677,6 +693,20 @@ describe("AgentChatComposer", () => {
expect(screen.getByRole("option", { name: /Bypass/ })).toBeTruthy();
});

it("uses a compact permission menu without a selected-mode header", () => {
renderComposer({
sessionProvider: "claude",
modelId: "anthropic/claude-sonnet-5",
availableModelIds: ["anthropic/claude-sonnet-5"],
});

fireEvent.click(screen.getByRole("button", { name: "Claude permission mode" }));

const listbox = screen.getByRole("listbox", { name: "Claude permission mode" });
expect(listbox.style.width).toBe("240px");
expect(within(listbox).queryByText("Mode", { exact: true })).toBeNull();
});

it.each(CAPTION_FREE_PERMISSION_CASES)(
"renders title-only $provider permission rows",
({ triggerName, optionCount, overrides }) => {
Expand Down
41 changes: 15 additions & 26 deletions apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,8 @@ const COMPOSER_PERMISSION_TRIGGER_CLASS = cn(
"hover:border-violet-400/20 hover:bg-violet-500/[0.06] hover:text-fg",
);

const COMPOSER_COMPACT_MENU_WIDTH = 240;

type PermissionModeTone = "green" | "amber" | "blue" | "purple" | "red" | "slate";
type PermissionModeIconKind = "manual" | "auto" | "edit" | "plan" | "full" | "config" | "agent" | "agi";

Expand Down Expand Up @@ -715,7 +717,7 @@ function PermissionModePicker<Value extends string>({
{open && ref.current ? createPortal(
(() => {
const rect = ref.current.getBoundingClientRect();
const width = 284;
const width = COMPOSER_COMPACT_MENU_WIDTH;
const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - width - 8));
return (
<div
Expand All @@ -729,20 +731,7 @@ function PermissionModePicker<Value extends string>({
width,
}}
>
<div className="flex items-center gap-2 border-b border-white/[0.05] px-3 py-2">
<span className={cn("inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border", selectedTone.iconSurface)} aria-hidden>
<PermissionModeGlyph icon={selectedOption.icon} size={12} />
</span>
<div className="min-w-0">
<div className="font-mono text-[length:calc(var(--chat-font-size)*9/14)] font-bold uppercase tracking-[0.18em] text-muted-fg/50">
Mode
</div>
<div className="truncate font-sans text-[length:calc(var(--chat-font-size)*11/14)] text-fg/78">
{selectedOption.label}
</div>
</div>
</div>
<ul className="py-1">
<ul className="py-0.5">
{options.map((option) => {
const active = option.value === selectedValue;
const tone = PERMISSION_MODE_TONE_STYLES[option.tone];
Expand All @@ -758,16 +747,16 @@ function PermissionModePicker<Value extends string>({
setOpen(false);
}}
className={cn(
"flex w-full items-center gap-2.5 px-3 py-2 text-left font-sans transition-colors",
"flex w-full items-center gap-2 px-2.5 py-1.5 text-left font-sans transition-colors",
active ? tone.rowActive : "text-fg/72",
tone.rowHover,
)}
title={option.detail}
>
<span className={cn("inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border", tone.iconSurface)} aria-hidden>
<PermissionModeGlyph icon={option.icon} size={12} />
<span className={cn("inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border", tone.iconSurface)} aria-hidden>
<PermissionModeGlyph icon={option.icon} size={10} />
</span>
<span className="min-w-0 flex-1 truncate text-[length:calc(var(--chat-font-size)*11/14)] font-semibold leading-4">
<span className="min-w-0 flex-1 truncate text-[length:calc(var(--chat-font-size)*10/14)] font-semibold leading-4">
{option.label}
</span>
{active ? <Check size={12} weight="bold" className="shrink-0 opacity-80" /> : null}
Expand Down Expand Up @@ -952,15 +941,15 @@ type ActiveTurnSendMode = "inline" | "queue" | "interrupt";
const ACTIVE_TURN_SEND_COPY: Record<ActiveTurnSendMode, { label: string; description: string }> = {
inline: {
label: "Send during turn",
description: "Claude picks this up after the current tool step, before continuing.",
description: "After the current tool step.",
},
queue: {
label: "Send after turn",
description: "Stage this message until the current turn is fully finished.",
description: "When this turn finishes.",
},
interrupt: {
label: "Interrupt & send",
description: "Stop Claude's current model step and redirect it to this message now.",
description: "Stop and redirect Claude now.",
},
};

Expand Down Expand Up @@ -1062,7 +1051,7 @@ function ActiveTurnSendButton({
? createPortal(
(() => {
const rect = caretRef.current.getBoundingClientRect();
const width = 244;
const width = COMPOSER_COMPACT_MENU_WIDTH;
const left = Math.min(
Math.max(8, rect.right - width),
Math.max(8, window.innerWidth - width - 8),
Expand Down Expand Up @@ -1093,7 +1082,7 @@ function ActiveTurnSendButton({
setMenuOpen(false);
}}
className={cn(
"flex w-full items-start gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-white/[0.05]",
"flex w-full items-start gap-2 px-2.5 py-2 text-left transition-colors hover:bg-white/[0.05]",
index > 0 && "border-t border-white/[0.05]",
option === "interrupt" && "hover:bg-amber-500/[0.08]",
)}
Expand All @@ -1105,10 +1094,10 @@ function ActiveTurnSendButton({
<ActiveTurnSendIcon mode={option} size={13} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[length:calc(var(--chat-font-size)*11/14)] font-medium text-fg/85">
<span className="block text-[length:calc(var(--chat-font-size)*10/14)] font-medium text-fg/85">
{copy.label}
</span>
<span className="mt-0.5 block text-[length:calc(var(--chat-font-size)*9.5/14)] leading-[1.35] text-fg/40">
<span className="mt-0.5 block text-[length:calc(var(--chat-font-size)*8/14)] leading-[1.25] text-fg/40">
{copy.description}
</span>
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";

vi.mock("@lobehub/icons", () => {
const brand = () => {
Expand Down Expand Up @@ -72,6 +73,35 @@ afterEach(() => {
});

describe("ReasoningEffortPicker", () => {
it("rolls the effort label in the change direction", async () => {
const user = userEvent.setup();
function ControlledPicker() {
const [effort, setEffort] = useState<string | null>("medium");
return (
<ReasoningEffortPicker
modelId={ANTHROPIC_MODEL_ID}
reasoningEffort={effort}
onChange={setEffort}
/>
);
}

const { container } = render(<ControlledPicker />);
const trigger = screen.getByRole("button", { name: /Reasoning effort/i });
await user.click(trigger);

const effortLabel = () => container.ownerDocument.querySelector<HTMLElement>(".ade-reasoning-effort-word");
expect(effortLabel()?.getAttribute("data-direction")).toBe("idle");

await user.click(screen.getByRole("radio", { name: "High" }));
expect(effortLabel()?.textContent).toBe("High");
expect(effortLabel()?.getAttribute("data-direction")).toBe("up");

await user.click(screen.getByRole("radio", { name: "Low" }));
expect(effortLabel()?.textContent).toBe("Low");
expect(effortLabel()?.getAttribute("data-direction")).toBe("down");
});

it("renders nothing when the model has no reasoning tiers", () => {
const { container } = render(
<ReasoningEffortPicker
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { CaretDown, Question } from "@phosphor-icons/react";
import { CaretDown } from "@phosphor-icons/react";
import type { ModelDescriptor } from "../../../../shared/modelRegistry";
import { cn } from "../../ui/cn";
import { resolveModelDescriptorWithRuntimeCatalog } from "./modelCatalog";
Expand Down Expand Up @@ -222,6 +222,15 @@ export const ReasoningEffortPicker = memo(function ReasoningEffortPicker({
}, [descriptor?.defaultReasoningEffort, family, getReasoningForFamily, reasoningEffort, tiers, useFamilyDefaults]);

const activeIndex = displayedEffort ? tiers.findIndex((tier) => tier === displayedEffort) : -1;
const previousActiveIndexRef = useRef(activeIndex);
const effortTransitionDirection = activeIndex > previousActiveIndexRef.current
? "up"
: activeIndex < previousActiveIndexRef.current
? "down"
: "idle";
useEffect(() => {
previousActiveIndexRef.current = activeIndex;
}, [activeIndex]);
const activeTone = REASONING_TONE_STYLES[reasoningToneKeyForIndex(activeIndex, tiers.length)];
const activeLabel = activeIndex >= 0 ? tierLabel(tiers[activeIndex]!, useCodex56Labels) : "Auto";
const thumbPercent = activeIndex < 0 ? 0 : tierPercent(activeIndex, tiers.length);
Expand Down Expand Up @@ -450,12 +459,12 @@ export const ReasoningEffortPicker = memo(function ReasoningEffortPicker({
<span
key={displayedEffort ?? "auto"}
className="ade-reasoning-effort-word font-semibold"
data-direction={effortTransitionDirection}
style={{ color: activeTone.color }}
>
{activeLabel}
</span>
</div>
<Question size={13} weight="bold" className="shrink-0 text-muted-fg/45" aria-hidden />
</div>
<div className="mt-4 flex items-center justify-between font-sans text-[11px] leading-none text-muted-fg/62">
<span>Faster</span>
Expand Down
26 changes: 22 additions & 4 deletions apps/desktop/src/renderer/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -4134,9 +4134,16 @@ button:active, [role="button"]:active {
}

/* Model reasoning slider: progressive fill, active-tier pulse, and drag state. */
@keyframes ade-reasoning-effort-word-pop {
0% { opacity: 0; transform: translateY(3px); filter: blur(3px); }
100% { opacity: 1; transform: translateY(0); filter: blur(0); }
@keyframes ade-reasoning-effort-word-roll-up {
0% { opacity: 0; transform: translateY(65%) perspective(90px) rotateX(-78deg); filter: blur(2px); }
72% { opacity: 1; transform: translateY(-4%) perspective(90px) rotateX(6deg); filter: blur(0); }
100% { opacity: 1; transform: translateY(0) perspective(90px) rotateX(0); filter: blur(0); }
}

@keyframes ade-reasoning-effort-word-roll-down {
0% { opacity: 0; transform: translateY(-65%) perspective(90px) rotateX(78deg); filter: blur(2px); }
72% { opacity: 1; transform: translateY(4%) perspective(90px) rotateX(-6deg); filter: blur(0); }
100% { opacity: 1; transform: translateY(0) perspective(90px) rotateX(0); filter: blur(0); }
}

@keyframes ade-reasoning-fill-sheen {
Expand All @@ -4151,7 +4158,16 @@ button:active, [role="button"]:active {
}

.ade-reasoning-effort-word {
animation: ade-reasoning-effort-word-pop 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
display: inline-block;
transform-origin: center bottom;
}

.ade-reasoning-effort-word[data-direction="up"] {
animation: ade-reasoning-effort-word-roll-up 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

.ade-reasoning-effort-word[data-direction="down"] {
animation: ade-reasoning-effort-word-roll-down 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
Comment thread
arul28 marked this conversation as resolved.
}

.ade-reasoning-slider-fill {
Expand Down Expand Up @@ -4187,6 +4203,8 @@ button:active, [role="button"]:active {
@media (prefers-reduced-motion: reduce) {
.ade-toast-enter { animation: none; }
.ade-reasoning-effort-word,
.ade-reasoning-effort-word[data-direction="up"],
.ade-reasoning-effort-word[data-direction="down"],
.ade-reasoning-slider-fill::after,
.ade-reasoning-slider-fill-max {
animation: none;
Expand Down
2 changes: 1 addition & 1 deletion docs/features/chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ for its separate RPC, sync, storage, and UI contracts.
| `apps/desktop/src/renderer/lib/claudeAuthPrompt.ts` | Renderer-side classifier for Claude logged-out / `/login`-required error text. Drives the header and sticky login CTAs; matches both Claude-first wording and ADE's own "Authentication failed for &lt;model&gt;" classified message. |
| `apps/desktop/src/renderer/lib/openExternal.ts` | Renderer-side router for outbound URLs. Defines the `ADE_OPEN_BUILT_IN_BROWSER_EVENT` window event plus `openUrlInAdeBrowser(url)` and `openExternalUrl(url)`. `openUrlInAdeBrowser` dispatches the event (so any open `WorkSidebar` can flip to its Browser tab), then calls `window.ade.builtInBrowser.navigate({ url, newTab: true })`. Anything that is not a normal `http`/`https`/`about:blank` URL falls through to `window.ade.app.openExternal` (system browser). All in-renderer URL clicks (markdown links, lane-runtime open buttons, etc.) go through this helper so the user stays inside ADE. |
| `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` | Composer UI: single-session prompt entry, attachments, model/permission controls, slash commands, pending input answering, and parallel launch slot configuration. During an active Claude turn, the split Send caret selects inline, after-turn, or interrupt delivery without sending; the primary button and Enter execute the chosen mode. Staged messages expose send-during-turn, interrupt, cancel, and Edit-back-to-composer actions. Permission popover rows keep only the mode title in the visible row (the explanation remains in the tooltip/title) for every provider-backed picker. Codex MCP elicitations show Allow once / Deny, conditionally show Always allow, and expose safe URL authorization through ADE's browser. Pasted/dropped image attachments show pending thumbnails while temp files save, and native Electron clipboard images read bytes through `ade.app.readClipboardImage` then write them through `ade.agentChat.saveTempAttachment` so remote-bound chats receive a runtime-readable attachment path. The launch-prompt clipboard helper is gated separately from prompt copying: `launchPromptClipboardEnabled` controls copying and `launchPromptClipboardNoticeEnabled` controls whether composer reminder text is shown. Orchestration model-selection pending inputs decode the full agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) so the picker can show what the lead is spawning without preselecting a recommended model. |
| `apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx` | Shared reasoning slider. Supports pointer drag with nearest-tick snap, keyboard arrows/Home/End, a progressive filled gradient and active-tier pulse, GPT-5.6 labels (Light, Medium, High, Extra High, Max, and Ultra where supported), and an Ultra multi-agent usage note. Choosing or dragging to a tier leaves the popover open; outside click or Escape closes it. |
| `apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx` | Shared reasoning slider. Supports pointer drag with nearest-tick snap, keyboard arrows/Home/End, a progressive filled gradient, and a directional roll transition for the active tier label, GPT-5.6 labels (Light, Medium, High, Extra High, Max, and Ultra where supported), and an Ultra multi-agent usage note. Choosing or dragging to a tier leaves the popover open; outside click or Escape closes it. |
| `apps/desktop/src/renderer/components/chat/ChatModelSelectionPendingCard.tsx` | Pending-input card used when ADE asks the user to choose a model for a new or rerouted agent. It renders the agent briefing, touched files, run-after dependencies, provider/model controls, cancel/confirm states, and leaves the model unset until the user chooses one. |
| `apps/desktop/src/renderer/components/chat/ChatCursorCloudPanel.tsx` | Side panel for Cursor Cloud (background agents): lists existing cloud agents and runs for the lane, lets the user open an existing cloud chat in ADE, archive/unarchive/cancel, and stream run output. Backed by `ade.ai.cursorCloud.*` IPC. |
| `apps/desktop/src/renderer/components/chat/CursorCloudInlineLaunch.tsx` | Inline composer affordance for "Send to Cursor Cloud": picks repo + branch + Cursor Cloud-eligible model, optionally targeting a detected PR, and dispatches the prompt to a fresh cloud agent. |
Expand Down