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
48 changes: 48 additions & 0 deletions docs/input-correction-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Exact-text input correction audit

Search queries, public keys, code, and machine identifiers should not be silently
rewritten by an OS keyboard. Use `autoCorrect="off"`, `autoCapitalize="none"`, and
`spellCheck={false}` for those fields. Spellcheck alone does not disable correction.
Keep ordinary writing fields unchanged; a global `Input`/`Textarea` opt-out would
also degrade messages, descriptions, profile bios, feedback, and instructions.

## Included in the identity-preview change

- `SearchField` and `Combobox.Control` default to exact-text entry, with explicit
caller overrides still supported.
- `Textarea variant="code"` applies the same defaults; its prose variant does not.
- `channel-templates/TemplateFields.tsx` disables correction for agent name/npub search.
- Member search inherits the shared `SearchField` defaults.

## Follow-up candidates, not repaired by this change

Audited the following `src/` inputs on 2026-09-29. These need field-level decisions,
not a blanket default on every text field. Some already disable spellcheck or
capitalization, but do not explicitly disable autocorrect.

| Source | Exact-text fields |
| --- | --- |
| `app/PluginImport.tsx` | Repository URL, branch/tag |
| `app/AgentDefaultsCard.tsx` | Provider/model/effort, environment keys and values |
| `features/identity/IdentitySetup.tsx` | Secret-key import |
| `features/communities/CommunityDialog.tsx` | Relay URL and invite code |
| `features/browser/BrowserHostView.tsx` | Address bar |
| `features/profiles/AvatarEditor.tsx` | Picture URL and emoji |
| `bundled/hosted-communities/HostedCommunities.tsx` | Domain slug, transfer recipient npub |
| `bundled/agents/AgentSettingsFields.tsx` | API key including revealed state, workspace path, arguments JSON |
| `bundled/agents/AgentHarnessEditor.tsx` | Harness argument text |
| `bundled/agents/AgentEnvironmentEditor.tsx` | Environment keys and values |
| `bundled/agents/AgentModelPicker.tsx` | Model ID, host, model filter |
| `bundled/agents/AgentImport.tsx` | Import destination path |
| `bundled/channels/CreateChannelDialog.tsx` | Channel name |
| `bundled/channels/ChannelLifecycleDialog.tsx` | Exact channel-name confirmation |
| `bundled/workflows/WorkflowEditor.tsx` | Workflow identifiers and YAML |
| `bundled/workflows/WorkflowConditions.tsx` | Expressions and condition values |
| `bundled/workflows/WorkflowForm.tsx` | Emoji, durations, expressions, step/channel identifiers |
| `bundled/workflows/WorkflowWebhookFields.tsx` | URL, headers, body |

Use the code textarea variant where it already fits rather than duplicating its
defaults. For mixed inputs such as environment values and webhook bodies, preserve
exact bytes even when the value happens to resemble prose. Browser DOM tests prove
attributes and override behavior, not native keyboard behavior: confirm correction
and capitalization on macOS/iOS before claiming platform-level coverage.
22 changes: 11 additions & 11 deletions src/bundled/agents/AgentSettingsFields.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -521,20 +521,20 @@ it("adds a Pi provider API key for lookup and drops it when the provider changes
}),
}),
);
await waitFor(() =>
expect(
screen.getByRole("combobox", { name: "Model" }),
).not.toHaveAttribute("aria-busy", "true"),
);
await user.click(screen.getByRole("combobox", { name: "Model" }));
const model = screen.getByRole("combobox", { name: "Model" });
// Catalog completion does not settle the popup's deferred input focus.
await waitFor(() => expect(model).not.toHaveAttribute("aria-busy", "true"));
await waitFor(() => {
expect(model).toHaveFocus();
expect(model).toHaveAttribute("aria-expanded", "true");
});
await user.keyboard("{Escape}");
await waitFor(() =>
expect(screen.getByRole("combobox", { name: "Model" })).toHaveAttribute(
"aria-expanded",
"false",
),
expect(model).toHaveAttribute("aria-expanded", "false"),
);
await user.click(
await screen.findByRole("combobox", { name: "LLM Provider" }),
);
await user.click(screen.getByRole("combobox", { name: "LLM Provider" }));
await user.click(await screen.findByRole("option", { name: "Not set" }));
expect(screen.queryByLabelText("Google Gemini API key")).toBeNull();
expect(draft.environment).toEqual({});
Expand Down
5 changes: 5 additions & 0 deletions src/bundled/channel-templates/ChannelTemplatesDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { RelaySession } from "../../features/relay/session";
import {
useEffect,
useLayoutEffect,
Expand All @@ -22,6 +23,7 @@ import { AgentSelection, TemplateFields } from "./TemplateFields";
import styles from "../channels/ChannelTemplates.module.css";

export function ChannelTemplatesDialog({
session,
open,
onOpenChange,
kit,
Expand All @@ -30,6 +32,7 @@ export function ChannelTemplatesDialog({
notice,
active,
}: {
session?: RelaySession | undefined;
active(): boolean;
open: boolean;
onOpenChange(open: boolean): void;
Expand Down Expand Up @@ -150,6 +153,7 @@ export function ChannelTemplatesDialog({
</Field>
{draft.type === "team" ? (
<AgentSelection
session={session}
agents={agents}
selected={draft.agents}
onChange={(agents) => setDraft({ ...draft, agents })}
Expand All @@ -166,6 +170,7 @@ export function ChannelTemplatesDialog({
/>
</Field>
<TemplateFields
session={session}
value={draft}
onChange={(value) => setDraft({ ...draft, ...value })}
entries={state.entries}
Expand Down
62 changes: 34 additions & 28 deletions src/bundled/channel-templates/TemplateFields.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { RelaySession } from "../../features/relay/session";
import { AgentOwnerPreview } from "../../features/profiles/AgentOwnerPreview";
import { npubEncode } from "nostr-tools/nip19";
import { publicKeyLabels } from "../../shared/identity/public-key";
import { Avatar } from "../../shared/design-system/ui/Avatar";
import { ChoiceRow } from "../../shared/design-system/ui/ChoiceRow";
import { IdentityRow } from "../../shared/identity/IdentityRow";
import { useState } from "react";
import { Checkbox } from "../../shared/design-system/ui/Checkbox";
import { Field } from "../../shared/design-system/ui/Field";
Expand All @@ -16,10 +17,12 @@ import {
import styles from "../channels/ChannelTemplates.module.css";

export function AgentSelection({
session,
selected,
agents,
onChange,
}: {
session?: RelaySession | undefined;
selected: readonly string[];
agents: readonly AgentChoice[];
onChange(keys: string[]): void;
Expand All @@ -36,6 +39,9 @@ export function AgentSelection({
<div className={styles.stack}>
<Field label="Find individual agents">
<Input
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Name or npub"
Expand All @@ -49,35 +55,32 @@ export function AgentSelection({
.includes(search.toLowerCase()),
)
.map((agent) => (
<Checkbox
<IdentityRow
key={agent.pubkey}
label={
<ChoiceRow
leading={
<Avatar
alt=""
fallback={agent.name}
src={agent.avatar ?? null}
size="small"
shape="squircle"
/>
}
label={agent.name}
description={
<span title={npubEncode(agent.pubkey)}>
{labels.get(agent.pubkey)}
</span>
pubkey={agent.pubkey}
name={agent.name}
picture={agent.avatar}
isAgent
previewDetail={
session ? (
<AgentOwnerPreview session={session} pubkey={agent.pubkey} />
) : undefined
}
keyLabel={labels.get(agent.pubkey)}
render={(content, previewProps) => (
<Checkbox
{...previewProps}
label={content}
checked={selected.includes(agent.pubkey)}
onCheckedChange={(checked) =>
onChange(
checked
? [...selected, agent.pubkey]
: selected.filter((key) => key !== agent.pubkey),
)
}
/>
}
checked={selected.includes(agent.pubkey)}
onCheckedChange={(checked) =>
onChange(
checked
? [...selected, agent.pubkey]
: selected.filter((key) => key !== agent.pubkey),
)
}
)}
/>
))}
{!choices.length && (
Expand All @@ -91,12 +94,14 @@ export function AgentSelection({
}

export function TemplateFields({
session,
value,
onChange,
entries,
agents,
acceptedAgents,
}: {
session?: RelaySession | undefined;
value: Lineup;
onChange(value: Lineup): void;
acceptedAgents?: readonly string[] | undefined;
Expand Down Expand Up @@ -162,6 +167,7 @@ export function TemplateFields({
)}
</fieldset>
<AgentSelection
session={session}
selected={value.agents}
agents={agents}
onChange={(agents) => onChange({ ...value, agents })}
Expand Down
2 changes: 2 additions & 0 deletions src/bundled/channel-templates/TemplateSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ function Library({
)}
{open && (
<ChannelTemplatesDialog
session={session}
open={open}
onOpenChange={setOpen}
kit={session.channelKit}
Expand Down Expand Up @@ -129,6 +130,7 @@ export function SaveAsTemplate({
{error && <p role="alert">{error}</p>}
{draft && (
<ChannelTemplatesDialog
session={session}
open
onOpenChange={(open) => {
if (!open) setDraft(undefined);
Expand Down
26 changes: 17 additions & 9 deletions src/bundled/channel-templates/agent-selection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { SidebarPreferences } from "../../features/relay/sidebar-preference
import {
act,
cleanup,
render,
render as rtlRender,
screen,
waitFor,
within,
Expand Down Expand Up @@ -49,6 +49,9 @@ import { MentionPicker } from "../mentions/MentionPicker";
import { MentionCompletion } from "../mentions/MentionCompletion";
import type { CompletionResult } from "../../features/conversation/contracts";

const render = (ui: Parameters<typeof rtlRender>[0]) =>
rtlRender(ui, { wrapper: ToastProvider });

beforeEach(() => {
localStorage.clear();
vi.stubGlobal(
Expand Down Expand Up @@ -515,7 +518,7 @@ it("copies a complete managed lineup without an unused legacy warning", async ()
);
expect(
await screen.findByRole("checkbox", {
name: `Calvin ${formatPublicKey(test.fixture.agent.pubkey)}`,
name: `Calvin Agent · ${formatPublicKey(test.fixture.agent.pubkey)}`,
}),
).toBeChecked();
expect(
Expand Down Expand Up @@ -693,7 +696,7 @@ it("copies the saved Canvas and eligible member keys without silently creating a
).toHaveValue("# Saved plan");
expect(
screen.getByRole("checkbox", {
name: `Calvin ${formatPublicKey(test.fixture.agent.pubkey)}`,
name: `Calvin Agent · ${formatPublicKey(test.fixture.agent.pubkey)}`,
}),
).toBeChecked();
expect(
Expand Down Expand Up @@ -919,18 +922,23 @@ it("shows an avatar, searchable npub and removable unavailable keys without rend
/>,
);
const row = screen
.getByRole("checkbox", { name: `Calvin ${formatPublicKey(key)}` })
.getByRole("checkbox", { name: `Calvin Agent · ${formatPublicKey(key)}` })
.closest("label");
assert.exists(row);
expect(row.querySelector("img")).toHaveAttribute("src", avatar);
expect(screen.getByTitle(npubEncode(key))).toHaveTextContent(
formatPublicKey(key) ?? "",
);
expect(row).toHaveTextContent(formatPublicKey(key) ?? "");
const user = userEvent.setup();
await user.hover(row);
const preview = await screen.findByRole("dialog", {
name: "Calvin identity",
});
expect(preview).toHaveTextContent(npubEncode(key));
await user.unhover(row);
expect(document.body.textContent).not.toContain(key);
expect(document.body.textContent).not.toContain(missing);
await userEvent.click(
screen.getByRole("checkbox", {
name: `Unavailable agent ${formatPublicKey(missing)}`,
name: `Unavailable agent Agent · ${formatPublicKey(missing)}`,
}),
);
expect(change).toHaveBeenCalledWith([]);
Expand Down Expand Up @@ -1449,7 +1457,7 @@ it("settings offers only the managed namesake and saves its exact key; creation
await screen.findByRole("button", { name: "Save as template…" }),
);
const checkbox = await screen.findByRole("checkbox", {
name: `Calvin ${formatPublicKey(test.fixture.agent.pubkey)}`,
name: `Calvin Agent · ${formatPublicKey(test.fixture.agent.pubkey)}`,
});
expect(screen.getAllByRole("checkbox")).toHaveLength(1);
expect(checkbox).toBeChecked();
Expand Down
25 changes: 20 additions & 5 deletions src/bundled/channels/ChannelMembersDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { RelayEvent } from "../../features/relay/events";
import { PublishRejected } from "../../features/relay/outbox";
import { createAgentControl } from "../../features/agents/control";
import { controlFixture } from "../../features/agents/control-testing";
import { ToastProvider } from "../../shared/design-system/ui/Toast";
import { ChannelMembersButton } from "./ChannelMembersDialog";
const stops: (() => void)[] = [];
afterEach(() => {
Expand Down Expand Up @@ -99,11 +100,13 @@ async function setup(
expect(owner.session.channels.list().status).toBe("ready"),
);
render(
<ChannelMembersButton
session={owner.session}
channelId={id}
control={control}
/>,
<ToastProvider>
<ChannelMembersButton
session={owner.session}
channelId={id}
control={control}
/>
</ToastProvider>,
);
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Channel members" }));
Expand Down Expand Up @@ -284,6 +287,12 @@ it("finishes confirmed local-agent startup after closing and reopening during pu
t.hold();
await t.user.click(await t.search());
await vi.waitFor(() => expect(t.publish).toHaveBeenCalledOnce());
const preview = screen.getByRole("dialog", {
name: "Fixture agent identity",
});
await t.user.keyboard("{Escape}");
await vi.waitFor(() => expect(preview).not.toBeInTheDocument());
expect(screen.getByRole("dialog", { name: "Channel members" })).toBeVisible();
await t.user.keyboard("{Escape}");
await vi.waitFor(() =>
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
Expand All @@ -310,6 +319,12 @@ it("keeps closed-dialog startup failures recoverable without another membership
t.hold();
await t.user.click(await t.search());
await vi.waitFor(() => expect(t.publish).toHaveBeenCalledOnce());
const preview = screen.getByRole("dialog", {
name: "Fixture agent identity",
});
await t.user.keyboard("{Escape}");
await vi.waitFor(() => expect(preview).not.toBeInTheDocument());
expect(screen.getByRole("dialog", { name: "Channel members" })).toBeVisible();
await t.user.keyboard("{Escape}");
await vi.waitFor(() =>
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
Expand Down
Loading
Loading