diff --git a/webviews/scripts/design-tokens-baseline.jsonl b/webviews/scripts/design-tokens-baseline.jsonl index fe3630e7a5ea..1be8d7d88ef8 100644 --- a/webviews/scripts/design-tokens-baseline.jsonl +++ b/webviews/scripts/design-tokens-baseline.jsonl @@ -386,7 +386,7 @@ {"file":"agent-session/acpmux/styles.css","kind":"duration","property":"animation","values":{"acpmux-chief-mascot-float 2.8s cubic-bezier(.22,.7,.28,1) infinite":1}} {"file":"agent-session/acpmux/styles.css","kind":"easing","property":"animation","values":{"acpmux-chief-card-in var(--cmux-motion-quick) cubic-bezier(.22,.7,.28,1) both":1}} {"file":"agent-session/acpmux/styles.css","kind":"easing","property":"transition","values":{"background-color var(--acpmux-motion-hover) ease-out,color var(--acpmux-motion-hover) ease-out,box-shadow var(--acpmux-motion-hover) ease-out":1,"opacity var(--acpmux-motion-hover) ease-out":1}} -{"file":"agent-session/acpmux/styles.css","kind":"font-weight","property":"font-weight","values":{"500":4,"650":2,"700":1}} +{"file":"agent-session/acpmux/styles.css","kind":"font-weight","property":"font-weight","values":{"500":3,"650":2,"700":1}} {"file":"agent-session/acpmux/styles.css","kind":"height","property":"height","values":{"16px":4,"7px":1,".5px":1,"48px":1,"18px":1,"22px":6,"40px":3,"1px":1,"26px":5,"34px":1,"30px":1,"12px":1,"8px":2,"36px":1,"180px":2,"360px":1,"6px":2,"164px":1,"42px":1,"68px":1,"56px":3,"max(var(--ui-row-height,28px),calc(96px * var(--cmux-interface-scale,1)))":1,"44px":1,"17px":1,"21px":1,"5px":1}} {"file":"agent-session/acpmux/styles.css","kind":"height","property":"min-height","values":{"34px":2,"38px":1,"calc(var(--ds-control-row) + var(--ds-space-3) + 4 * var(--cmux-hairline,.5px))":1,"30px":1,"16px":1,"128px":1,"36px":1,"58px":1,"48px":1,"46px":1,"56px":1,"44px":1}} {"file":"agent-session/acpmux/styles.css","kind":"radius","property":"border-radius","values":{"22px":1,"14px":1,"3px":4,"5px":1,"9px":1,"48px":2,"24px":2,"20px":1}} diff --git a/webviews/src/agent-session/acpmux/PermissionCard.gallery.tsx b/webviews/src/agent-session/acpmux/PermissionCard.gallery.tsx index 64b842eaf7ce..03880e2e5301 100644 --- a/webviews/src/agent-session/acpmux/PermissionCard.gallery.tsx +++ b/webviews/src/agent-session/acpmux/PermissionCard.gallery.tsx @@ -4,8 +4,10 @@ // button, like this, it shouldn't be encompassing so much ... this other, like, thing shouldn't be // the same thing." Then, on equal-width buttons stretched across the card: "but there's literally // no point having it be the same size? just make it good? incl. the symbol hints". The buttons are -// sized to their labels with a light key hint; the scope is one muted line. scopeStyle and rowStyle -// draw gallery-only alternatives over the shipped card; "under" and "light" are the shipped ones. +// sized to their labels; the scope is one muted line. Leo 2026-11-05, on Claude desktop's card: a +// bold title over a muted reason, a quiet details band, then Deny (esc) and the filled Allow once +// (⌘↩) at the trailing edge with their keycaps. scopeStyle and rowStyle draw gallery-only +// alternatives over the shipped card. import type { ComponentProps } from "react"; import { componentEntry } from "../../gallery/format"; import type { Play } from "../../gallery/play"; @@ -232,7 +234,7 @@ export default componentEntry({ props: { permission: shortRule, harness: "claude" }, }, "long-rule": { - note: "Leo 2026-10-25: a long Claude session rule. Compact buttons sized to their labels, grouped at the leading edge, each with a light ⌥⌘ hint; the rule is one muted line under them, cut, whole on hover.", + note: 'Leo 2026-11-05, on Claude desktop\'s "Allow Claude to use …?" card: "The whole, like, allow whatever to use, allow for this session, or deny. And then it shows the hotkeys." A bold title and a muted reason, a quiet details band with the command and the long rule on one muted line (cut, whole on hover), then the answers at the trailing edge: Deny with its esc keycap, Always allow with ⌥⌘2, and the filled Allow once with ⌘↩.', props: { permission: longRule, harness: "claude" }, play: scopeOnce, }, diff --git a/webviews/src/agent-session/acpmux/PermissionCard.tsx b/webviews/src/agent-session/acpmux/PermissionCard.tsx index 10b27ed953a9..3d1656bb09f3 100644 --- a/webviews/src/agent-session/acpmux/PermissionCard.tsx +++ b/webviews/src/agent-session/acpmux/PermissionCard.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "re import { Markdown } from "./conversation/Markdown"; import { toolIcon } from "./conversation/ToolRow"; import { useT } from "./i18n"; +import { Keycap } from "./Keycap"; import type { AcpmuxPermission } from "./model"; import { ProposedEdits } from "./permissions/ProposedEdits"; import { @@ -71,14 +72,25 @@ function KeyHint({ shortcut }: { shortcut: string }) { ); } +/// A key the card itself answers (Esc, ⌘↩), drawn as a keycap in its button: `label` as shown, +/// `aria` in `aria-keyshortcuts` form. +export type CardKey = { keys: string[]; aria: string }; + /** Shared action button so grouped and legacy cards present the same localized label and key hint. * Only the short label and the key: what an answer covers is the card's one scope line, never the - * button's (Leo 2026-10-25: "the allow button ... it shouldn't be encompassing so much"). */ + * button's (Leo 2026-10-25: "the allow button ... it shouldn't be encompassing so much"). With + * `cardKey` the button shows that key as a keycap, as Claude desktop's "Deny Esc" and "Allow ⌘↩", + * and still answers `shortcut`, which it lists after the card key. */ export function PermissionActionButton({ label, shortcut, + cardKey, ...props -}: React.ButtonHTMLAttributes & { label: string; shortcut?: string }) { +}: React.ButtonHTMLAttributes & { + label: string; + shortcut?: string; + cardKey?: CardKey; +}) { const { className, ...buttonProps } = props; return ( ); } -/// A permission ask as Claude Code desktop draws it: a glass card with the tool's glyph and what the -/// call does as its title, the command clamped to its first line ("Show full command" opens all of -/// it), and Allow once, Always allow and Deny, each shown only when the agent offered that kind -/// (permissions/prompt.ts), with any other option the agent offered after them: short buttons sized -/// to their labels, grouped at the card's leading edge. What they cover (the folder or app, the Always allow rule) is one muted -/// line under them, cut with an ellipsis and whole on hover. Deny opens a field for what the agent should do instead, sent with the -/// deny through `onDenyWithFeedback`. Keys 1, 2 and 3, Return (Allow once) and Esc (Deny, or close +// One keycap per key, as Claude desktop draws "Esc" and "⌘ ↩". +const DENY_KEY: CardKey = { keys: ["Esc"], aria: "Escape" }; +const ALLOW_KEY: CardKey = { keys: ["⌘", "↩"], aria: "Meta+Enter" }; + +/// A permission ask as Claude desktop draws its "Allow Claude to use …?" card (Leo, 2026-11-05: "The +/// whole, like, allow whatever to use, allow for this session, or deny. And then it shows the +/// hotkeys."): a glass card whose head, a shade lighter than the rest, holds what the call does, with +/// the tool's glyph, and a muted reason under it; then the details, the command clamped to its first line ("Show full +/// command" opens all of it) and what an answer covers (the folder or app, the Always allow rule) as +/// one muted line, cut with an ellipsis and whole on hover; then the answers, short buttons sized to +/// their labels: any other option the agent offered and Deny with its Esc keycap at the leading +/// edge, Always allow and the filled Allow once with its ⌘ ↩ keycaps at the trailing edge, each shown only when the +/// agent offered that kind (permissions/prompt.ts). Deny opens a field for what the agent should do instead, sent with the +/// deny through `onDenyWithFeedback`. Keys 1, 2 and 3, Return and ⌘↩ (Allow once) and Esc (Deny, or close /// the field) answer while this card is the prompt and nothing else has the keyboard. In a chat /// running its highest mode (`fullAccessMode`), an ask still here is one the mode does not answer /// (acpmux hub/permissions.rs `full_access_allow`: an ask Claude Code marks as needing the person, @@ -394,6 +421,38 @@ export function PermissionCard({ if (onCard) field.current?.focus(); }; + const commandBlock = command && ( +
+ {showFull ? ( +
+          {command}
+        
+ ) : ( +
+ {preview.line} +
+ )} + {(preview.more || clamped || showFull) && ( + + )} +
+ ); + const scopeBlock = !writing && scopeLine && ( +

+ {scopeText && {scopeText}} + {scopeText && alwaysScope && " · "} + {alwaysScope && {alwaysScope}} +

+ ); + return (
@@ -418,63 +477,21 @@ export function PermissionCard({ {plan}
)} - {command && ( -
- {showFull ? ( -
-                {command}
-              
- ) : ( -
- {preview.line} -
- )} - {(preview.more || clamped || showFull) && ( - - )} -
- )} + {(commandBlock || scopeBlock) && ( +
+ {commandBlock} + {scopeBlock} +
+ )} + {!writing && refused === permission.permissionId && ( +

+ {t("permission.error.refused")} +

+ )} {!writing && (
- {allowOnce && ( - answer(allowOnce)} - /> - )} - {allowAlways && allowAlwaysLabel && ( - answer(allowAlways)} - /> - )} - {denyOption && ( - deny()} - /> - )} {others.map((option, index) => { const label = otherLabels[index]!; const full = [option.name.trim(), option.description?.trim()].filter(Boolean).join(" · "); @@ -497,20 +514,46 @@ export function PermissionCard({ ); })} + {denyOption && ( + deny()} + /> + )} + {(allowAlways || allowOnce) && ( + // The allows keep the trailing edge together, so a narrow card wraps them as a pair. + + {allowAlways && allowAlwaysLabel && ( + answer(allowAlways)} + /> + )} + {allowOnce && ( + answer(allowOnce)} + /> + )} + + )}
)} - {!writing && refused === permission.permissionId && ( -

- {t("permission.error.refused")} -

- )} - {!writing && scopeLine && ( -

- {scopeText && {scopeText}} - {scopeText && alwaysScope && " · "} - {alwaysScope && {alwaysScope}} -

- )} {writing && (
- -
diff --git a/webviews/src/agent-session/acpmux/conversation/Thinking.tsx b/webviews/src/agent-session/acpmux/conversation/Thinking.tsx index 9b7e9db249a1..8313075931a0 100644 --- a/webviews/src/agent-session/acpmux/conversation/Thinking.tsx +++ b/webviews/src/agent-session/acpmux/conversation/Thinking.tsx @@ -7,7 +7,10 @@ // status and no Cancel (Leo, 2026-11-01), so the line keeps one place and one look from send to // the first output (cx-ncc.58). Only the status is the live region. While the chat waits on the person (an // ask above the composer, waitingOn.ts), the line says that instead, with no time and no sweep: -// never "Thinking" while the agent is blocked on them. +// never "Thinking" while the agent is blocked on them. It reads as Claude desktop's "Needs your input" +// pill: the attention dot and the words on a tinted pill. Once answered, the dot shrinks into the +// Thinking line that takes its place (Leo, 2026-11-05: "there's a little animation that plays. It, +// like, shrinks the thing, the circle into the Thinking... portion afterwards which is cute"). import { useContext, useLayoutEffect, useMemo, useRef, useState } from "react"; import { experimentArm } from "../../../experiments/experiment"; import { usePaneLanguage, useT } from "../i18n"; @@ -18,7 +21,7 @@ import { BackgroundHint } from "./BackgroundHint"; import { useSubsessionInfo } from "../subagents/subsessions"; import { ShimmerText } from "./ShimmerText"; import { formatDuration, spanDuration } from "./turns"; -import { WAITING_LABEL, WaitingOnContext } from "./waitingOn"; +import { WAITING_LABEL, type WaitingOn, WaitingOnContext } from "./waitingOn"; import { compactTokens, hasTokens, tokenDetail, TurnTokens } from "./TurnTokens"; import { verbLeads } from "./ToolVerb"; import { useTicking, WorkingCountsContext, workingParts } from "./WorkingFor"; @@ -133,6 +136,28 @@ export function liveLead(label: string, leads: boolean, call: boolean): { lead: /// wait never reads as that long a think or call. All shimmer. While text streams (`quietKey` moves) the /// line stays where it is and reads "Writing": it never leaves and comes back mid-turn (Leo, /// 2026-11-04: "it appears and disappears????"). +/// The longest the answered dot stays, should its shrink never end (motion off draws no animation). +const SETTLE_MS = 400; + +/// True from the render the chat stops waiting on the person until the dot's shrink ends (`done`) +/// or SETTLE_MS passes: the line draws the shrinking dot before its words for that long. +function useAnswered(waiting: WaitingOn | undefined): [boolean, () => void] { + const [answered, setAnswered] = useState(false); + const was = useRef(waiting); + useLayoutEffect(() => { + const before = was.current; + was.current = waiting; + if (waiting || !before) { + setAnswered(false); + return; + } + setAnswered(true); + const timer = setTimeout(() => setAnswered(false), SETTLE_MS); + return () => clearTimeout(timer); + }, [waiting]); + return [answered, () => setAnswered(false)]; +} + export function Thinking({ starting = false, since, @@ -201,6 +226,7 @@ export function Thinking({ const t = useT(); const language = usePaneLanguage(); const waiting = useContext(WaitingOnContext); + const [answered, settled] = useAnswered(waiting); const startLook = starting && !waiting ? experimentArm(agentStartLook) : "thinking"; // The turn's time, from the prompt, whatever the line names: it keeps counting across steps and // never starts again at a call (Leo, 2026-11-01: "it should just keep going"). @@ -328,10 +354,14 @@ export function Thinking({
{waiting ? ( - {t(WAITING_LABEL[waiting])} + ) : ( + {answered && ( +