fix(web): keep browser autofill out of config forms; drop the closed panel's stray divider (#114)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,39 @@ export const errorClass =
|
||||
"!border-red-400 !bg-red-50 hover:!border-red-500 focus:!border-red-500 focus:!ring-red-400/30 " +
|
||||
"dark:!border-red-800 dark:!bg-red-950/40 dark:hover:!border-red-700 dark:focus:!border-red-600";
|
||||
|
||||
/**
|
||||
* Autofill policy: a control opts OUT unless its caller declares a real credential role.
|
||||
* Almost every field in this app holds an API key, a model id, a URL, a directory or a
|
||||
* price, and the browser's saved-login heuristics kept dropping the account's username and
|
||||
* password into them (a dialog's fields are unowned — no <form> element — so the browser
|
||||
* groups them with everything else on the page and picks a "username" box on its own).
|
||||
* Only login.tsx and the password dialogs pass a role ("username" / "current-password" /
|
||||
* "new-password"), and those keep the browser's help.
|
||||
*
|
||||
* `autocomplete="off"` alone does NOT cover a password box: Chrome and Safari ignore it
|
||||
* there and offer the saved login anyway. An opted-out SECRET field therefore goes out as
|
||||
* `new-password` — the one value password managers read as "not the account password" —
|
||||
* and both cases carry the manager-extension opt-outs (1Password / LastPass / Bitwarden /
|
||||
* Dashlane), which read their own attributes rather than `autocomplete`.
|
||||
*/
|
||||
export function autofillProps(autoComplete: string | undefined, secret: boolean) {
|
||||
// A declared role (anything but the opt-out) is the caller's decision: pass it through untouched.
|
||||
if (autoComplete !== undefined && autoComplete !== "off") return { autoComplete };
|
||||
return {
|
||||
autoComplete: secret ? "new-password" : "off",
|
||||
"data-1p-ignore": "",
|
||||
"data-lpignore": "true",
|
||||
"data-bwignore": "",
|
||||
"data-form-type": "other",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The same opt-out as a spreadable constant, for the handful of raw `<input>`s that don't go
|
||||
* through Input (menu search boxes, the Workspace path editor): `{...noAutofill}`.
|
||||
*/
|
||||
export const noAutofill = autofillProps(undefined, false);
|
||||
|
||||
export function Input({
|
||||
label,
|
||||
hint,
|
||||
@@ -56,6 +89,7 @@ export function Input({
|
||||
required,
|
||||
size = "base",
|
||||
className,
|
||||
autoComplete,
|
||||
...rest
|
||||
}: InputProps) {
|
||||
const bad = Boolean(error) || Boolean(invalid);
|
||||
@@ -70,6 +104,9 @@ export function Input({
|
||||
aria-invalid={bad ? true : undefined}
|
||||
aria-required={required || undefined}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
// Secret while masked; PasswordInput's reveal toggle flips the type to text, and the
|
||||
// opt-out that matters was already read from the password state.
|
||||
{...autofillProps(autoComplete, rest.type === "password")}
|
||||
{...rest}
|
||||
/>
|
||||
</Field>
|
||||
@@ -89,7 +126,7 @@ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElemen
|
||||
}
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
|
||||
{ label, hint, error, invalid, required, mono, size = "base", className, ...rest },
|
||||
{ label, hint, error, invalid, required, mono, size = "base", className, autoComplete, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const bad = Boolean(error) || Boolean(invalid);
|
||||
@@ -102,6 +139,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function
|
||||
aria-invalid={bad ? true : undefined}
|
||||
aria-required={required || undefined}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
{...autofillProps(autoComplete, false)}
|
||||
{...rest}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -65,6 +65,7 @@ import { resolveContextWindow } from "../../lib/context";
|
||||
import { useLocale } from "../../state/locale";
|
||||
import { Dropdown } from "../../components/ui/dropdown";
|
||||
import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||
import { noAutofill } from "../../components/ui/input";
|
||||
import { SkillIcon } from "../skills/skill-icon-view";
|
||||
import { ZoomableImage } from "../../components/ui/image-zoom";
|
||||
import { ProviderLogo } from "../../components/ui/provider-logo";
|
||||
@@ -280,6 +281,7 @@ function ModelMenuList({
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={S.models.searchPlaceholder}
|
||||
aria-label={S.models.searchPlaceholder}
|
||||
{...noAutofill}
|
||||
className="w-full rounded border border-transparent bg-transparent px-1 py-0.5 text-xs text-gray-700 placeholder:text-gray-400 focus:outline-none dark:text-gray-200 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
@@ -680,6 +682,7 @@ function SkillSelect({
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={S.chat.skillsSearchPlaceholder}
|
||||
aria-label={S.chat.skillsSearchPlaceholder}
|
||||
{...noAutofill}
|
||||
className="w-full rounded border border-transparent bg-transparent px-1 py-0.5 text-xs text-gray-700 placeholder:text-gray-400 focus:outline-none dark:text-gray-200 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
@@ -2022,6 +2025,7 @@ export function ChatInput({
|
||||
placeholder={S.chat.goalBudgetPlaceholder}
|
||||
aria-invalid={goalBudgetDraftInvalid}
|
||||
aria-describedby="goal-budget-hint"
|
||||
{...noAutofill}
|
||||
title={
|
||||
goalBudgetDraftInvalid ? S.chat.goalBudgetInvalid : S.chat.goalBudgetHint
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ import { useSessions } from "../../state/sessions";
|
||||
import { AgentAvatar } from "../../components/ui/agent-avatar";
|
||||
import { Chevron } from "../../components/ui/chevron";
|
||||
import { Dropdown } from "../../components/ui/dropdown";
|
||||
import { noAutofill } from "../../components/ui/input";
|
||||
import { PenguinLogo } from "../../components/ui/penguin-logo";
|
||||
import { toastError } from "../../components/ui/toast";
|
||||
import { useVersionInfo } from "../../lib/use-version-info";
|
||||
@@ -967,6 +968,7 @@ function WorkspaceSelect({
|
||||
value={pathDraft}
|
||||
placeholder="…"
|
||||
aria-label={S.chat.workspace}
|
||||
{...noAutofill}
|
||||
onChange={(e) => setPathDraft(e.target.value)}
|
||||
onBlur={() => void commitPathEdit()}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
@@ -74,9 +74,15 @@ export function FilesPanel({ session, panel }: { session: SessionInfo; panel: Fi
|
||||
// input) anchored to the nearest initial containing block instead, it would bypass this
|
||||
// overflow-hidden and stretch the **document** wide, making a horizontal scrollbar
|
||||
// appear out of nowhere.
|
||||
className={`relative flex min-h-0 shrink-0 flex-col overflow-hidden border-l border-gray-200 dark:border-gray-800 ${
|
||||
panel.resizing ? "pointer-events-none" : "transition-[width] duration-200"
|
||||
}`}
|
||||
//
|
||||
// The divider belongs to the OPEN state only: with border-box sizing a closed panel's
|
||||
// border still paints its 1px even at width 0, and since both docked panels stay
|
||||
// mounted, the closed one left a stray line beside the open one — a hairline, a gap
|
||||
// (the open panel's resize handle) and then the real divider, which reads as an extra
|
||||
// empty panel wedged in. Both closed, the two leftover lines stacked at the window edge.
|
||||
className={`relative flex min-h-0 shrink-0 flex-col overflow-hidden ${
|
||||
panel.open ? "border-l border-gray-200 dark:border-gray-800" : ""
|
||||
} ${panel.resizing ? "pointer-events-none" : "transition-[width] duration-200"}`}
|
||||
>
|
||||
{/* Content is fixed at the target width; the outer element is only a clipping window:
|
||||
during the open/close animation the outer element passes through intermediate
|
||||
|
||||
@@ -80,11 +80,13 @@ export function SubagentsPanel({
|
||||
<div
|
||||
ref={panel.panelRef}
|
||||
style={{ width: panel.open ? panel.width : 0 }}
|
||||
// Same inert + clipping-window handling as the Files panel (see files-panel.tsx).
|
||||
// Same inert + clipping-window handling as the Files panel, and the same open-only
|
||||
// divider (a closed panel's 1px border would otherwise paint next to the open one —
|
||||
// see files-panel.tsx).
|
||||
inert={!panel.open}
|
||||
className={`relative flex min-h-0 shrink-0 flex-col overflow-hidden border-l border-gray-200 dark:border-gray-800 ${
|
||||
panel.resizing ? "pointer-events-none" : "transition-[width] duration-200"
|
||||
}`}
|
||||
className={`relative flex min-h-0 shrink-0 flex-col overflow-hidden ${
|
||||
panel.open ? "border-l border-gray-200 dark:border-gray-800" : ""
|
||||
} ${panel.resizing ? "pointer-events-none" : "transition-[width] duration-200"}`}
|
||||
>
|
||||
<div style={{ width: panel.width }} className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex shrink-0 items-center gap-1 px-3 pt-2">
|
||||
|
||||
Reference in New Issue
Block a user