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:
Yaowei Zheng
2026-07-29 16:13:13 +08:00
committed by GitHub
parent f865343c90
commit 5530b7ffad
8 changed files with 146 additions and 9 deletions
+39 -1
View File
@@ -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">