diff --git a/docs/input-correction-audit.md b/docs/input-correction-audit.md new file mode 100644 index 000000000..4f0ed9b94 --- /dev/null +++ b/docs/input-correction-audit.md @@ -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. diff --git a/src/bundled/agents/AgentSettingsFields.test.tsx b/src/bundled/agents/AgentSettingsFields.test.tsx index fcd7aad49..863108c91 100644 --- a/src/bundled/agents/AgentSettingsFields.test.tsx +++ b/src/bundled/agents/AgentSettingsFields.test.tsx @@ -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({}); diff --git a/src/bundled/channel-templates/ChannelTemplatesDialog.tsx b/src/bundled/channel-templates/ChannelTemplatesDialog.tsx index d396f19bc..9937c09d9 100644 --- a/src/bundled/channel-templates/ChannelTemplatesDialog.tsx +++ b/src/bundled/channel-templates/ChannelTemplatesDialog.tsx @@ -1,3 +1,4 @@ +import type { RelaySession } from "../../features/relay/session"; import { useEffect, useLayoutEffect, @@ -22,6 +23,7 @@ import { AgentSelection, TemplateFields } from "./TemplateFields"; import styles from "../channels/ChannelTemplates.module.css"; export function ChannelTemplatesDialog({ + session, open, onOpenChange, kit, @@ -30,6 +32,7 @@ export function ChannelTemplatesDialog({ notice, active, }: { + session?: RelaySession | undefined; active(): boolean; open: boolean; onOpenChange(open: boolean): void; @@ -150,6 +153,7 @@ export function ChannelTemplatesDialog({ {draft.type === "team" ? ( setDraft({ ...draft, agents })} @@ -166,6 +170,7 @@ export function ChannelTemplatesDialog({ /> setDraft({ ...draft, ...value })} entries={state.entries} diff --git a/src/bundled/channel-templates/TemplateFields.tsx b/src/bundled/channel-templates/TemplateFields.tsx index cf1347a02..51fbffed9 100644 --- a/src/bundled/channel-templates/TemplateFields.tsx +++ b/src/bundled/channel-templates/TemplateFields.tsx @@ -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"; @@ -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; @@ -36,6 +39,9 @@ export function AgentSelection({
setSearch(e.target.value)} placeholder="Name or npub" @@ -49,35 +55,32 @@ export function AgentSelection({ .includes(search.toLowerCase()), ) .map((agent) => ( - - } - label={agent.name} - description={ - - {labels.get(agent.pubkey)} - + pubkey={agent.pubkey} + name={agent.name} + picture={agent.avatar} + isAgent + previewDetail={ + session ? ( + + ) : undefined + } + keyLabel={labels.get(agent.pubkey)} + render={(content, previewProps) => ( + + 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 && ( @@ -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; @@ -162,6 +167,7 @@ export function TemplateFields({ )} onChange({ ...value, agents })} diff --git a/src/bundled/channel-templates/TemplateSettings.tsx b/src/bundled/channel-templates/TemplateSettings.tsx index ddbcff9f1..2379fcfb3 100644 --- a/src/bundled/channel-templates/TemplateSettings.tsx +++ b/src/bundled/channel-templates/TemplateSettings.tsx @@ -70,6 +70,7 @@ function Library({ )} {open && ( {error}

} {draft && ( { if (!open) setDraft(undefined); diff --git a/src/bundled/channel-templates/agent-selection.test.tsx b/src/bundled/channel-templates/agent-selection.test.tsx index 7399b8b1d..c1934ad55 100644 --- a/src/bundled/channel-templates/agent-selection.test.tsx +++ b/src/bundled/channel-templates/agent-selection.test.tsx @@ -7,7 +7,7 @@ import type { SidebarPreferences } from "../../features/relay/sidebar-preference import { act, cleanup, - render, + render as rtlRender, screen, waitFor, within, @@ -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[0]) => + rtlRender(ui, { wrapper: ToastProvider }); + beforeEach(() => { localStorage.clear(); vi.stubGlobal( @@ -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( @@ -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( @@ -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([]); @@ -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(); diff --git a/src/bundled/channels/ChannelMembersDialog.test.tsx b/src/bundled/channels/ChannelMembersDialog.test.tsx index 9ca2d8c52..323efffa7 100644 --- a/src/bundled/channels/ChannelMembersDialog.test.tsx +++ b/src/bundled/channels/ChannelMembersDialog.test.tsx @@ -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(() => { @@ -99,11 +100,13 @@ async function setup( expect(owner.session.channels.list().status).toBe("ready"), ); render( - , + + + , ); const user = userEvent.setup(); await user.click(screen.getByRole("button", { name: "Channel members" })); @@ -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(), @@ -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(), diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx index 8e2589225..9a0c74b3c 100644 --- a/src/bundled/channels/ChannelMembersDialog.tsx +++ b/src/bundled/channels/ChannelMembersDialog.tsx @@ -19,7 +19,8 @@ import { Dialog } from "../../shared/design-system/ui/Dialog"; import { IconButton } from "../../shared/design-system/ui/IconButton"; import { Button } from "../../shared/design-system/ui/Button"; import { SearchField } from "../../shared/design-system/ui/SearchField"; -import { Avatar } from "../../shared/design-system/ui/Avatar"; +import { AgentOwnerPreview } from "../../features/profiles/AgentOwnerPreview"; +import { IdentityRow } from "../../shared/identity/IdentityRow"; import { NavigationItem } from "../../shared/design-system/ui/NavigationItem"; import { UsersIcon } from "../../shared/design-system/icons"; import { useMemberSearch } from "./useMemberSearch"; @@ -252,52 +253,50 @@ export function ChannelMembersDialog({ ) => { const isAgent = agent || known.has(key) || profiles.get(key)?.isAgent; const artwork = picture ?? profiles.get(key)?.picture; - const content = ( - <> - - {name} - {key === session.viewer ? " (you)" : ""} - - - {isAgent ? "Agent · " : ""} - {keys.get(key)} - {archived.has(key) ? " · Archived" : ""} - - - ); - const avatar = ( - + ) : undefined + } + detail={archived.has(key) ? "Archived" : undefined} + render={ + adding + ? (content, previewProps) => ( + { + if (focusedAdd.current?.button === event.currentTarget) + focusedAdd.current = null; + }} + onClick={(event) => { + if (busy.has(key)) return; + if (document.activeElement === event.currentTarget) + focusedAdd.current = { key, button: event.currentTarget }; + void add(key); + }} + /> + ) + : undefined + } /> ); return adding ? ( - { - if (focusedAdd.current?.button === event.currentTarget) - focusedAdd.current = null; - }} - onClick={(event) => { - if (busy.has(key)) return; - if (document.activeElement === event.currentTarget) - focusedAdd.current = { key, button: event.currentTarget }; - void add(key); - }} - /> +
{identity}
) : ( -
  • - {avatar} - {content} +
  • + {identity}
  • ); }; diff --git a/src/bundled/profiles/InstanceProfilePanel.tsx b/src/bundled/profiles/InstanceProfilePanel.tsx index 6ddad8965..726605da5 100644 --- a/src/bundled/profiles/InstanceProfilePanel.tsx +++ b/src/bundled/profiles/InstanceProfilePanel.tsx @@ -1,3 +1,4 @@ +import { useAgentOwnerEvidence } from "../../features/profiles/useAgentOwnerEvidence"; import { useState } from "react"; import { useRelayConnection } from "../../features/relay/react"; import type { RelayData } from "../../features/relay/service"; @@ -14,7 +15,6 @@ import { } from "../../features/profiles/instance-target"; import { profileTarget } from "../../features/profiles/target"; import { Button } from "../../shared/design-system/ui/Button"; -import { useAgentOwnerEvidence } from "./ProfileAgentIdentity"; import { ProfilePanel } from "./ProfilePanel"; /** Host retains the exact target and owns replacement, close and focus restoration. diff --git a/src/bundled/profiles/ProfileAgentIdentity.tsx b/src/bundled/profiles/ProfileAgentIdentity.tsx index 80422949e..ac00e6293 100644 --- a/src/bundled/profiles/ProfileAgentIdentity.tsx +++ b/src/bundled/profiles/ProfileAgentIdentity.tsx @@ -1,150 +1,13 @@ -import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; -import { attestedOwner } from "../../features/agents/owner-attestation"; +import { useEffect, useMemo, useSyncExternalStore } from "react"; import { useIdentityNames } from "../../features/identity-names/react"; import type { PanelProps } from "../../features/panels/service"; import { profileTarget } from "../../features/profiles/target"; -import { type EventData, newer } from "../../features/relay/events"; import { selectProfiles } from "../../features/relay/profile-selection"; -import type { - EventViewSnapshot, - RelaySession, -} from "../../features/relay/session"; +import type { RelaySession } from "../../features/relay/session"; import { Button } from "../../shared/design-system/ui/Button"; import { formatPublicKey } from "../../shared/identity/public-key"; import styles from "./Profiles.module.css"; -type ProfileView = ReturnType; -const noSubscribe = () => () => {}; -const pendingView = (): EventViewSnapshot => pendingSnapshot; -const pendingSnapshot: EventViewSnapshot = Object.freeze({ - status: "loading", - events: [], -}); - -/** Verified NIP-OA owner of the winning signed kind 0, or none. Agent hints - * decide whether to mount this view; they never establish ownership. - * Private admission must inspect readiness as well as signed-head ownership. - * Public identity attribution may still display its retained signed evidence. */ -export function useAgentOwnerEvidence( - session: RelaySession, - pubkey: string | undefined, - attempt = 0, -): { - status: "loading" | "ready" | "error" | "unavailable"; - owner: string | undefined; - failed: boolean; - settled: boolean; -} { - // A session-owned view: live events, reconnect refresh and purge, no polling. - // Capacity or a closed session leaves no view; profile recovery retries. - const [view, setView] = useState(); - // biome-ignore lint/correctness/useExhaustiveDependencies: attempt retries view admission. - useEffect(() => { - if (!pubkey) { - setView(null); - return; - } - let owned: ProfileView; - try { - owned = session.observe([{ kinds: [0], authors: [pubkey], limit: 1 }]); - } catch { - setView(null); - return; - } - setView(owned); - return owned.dispose; - }, [session, pubkey, attempt]); - const events = useSyncExternalStore( - view?.subscribe ?? noSubscribe, - view?.snapshot ?? pendingView, - view?.snapshot ?? pendingView, - ); - // Provenance belongs to the winning signed kind 0 alone, never a display projection. - // The directory's retained head outlives this view, so a reopened pane cannot - // accept an older response than the profile the rest of the session shows. - // Subscribed, so any head change (live, read or disk restore) re-renders. - // Without a live view nothing can signal an auth-only change, so show nothing. - const directoryHead = useSyncExternalStore( - session.profiles.subscribe, - () => (pubkey ? session.profiles.event?.(pubkey) : undefined), - () => (pubkey ? session.profiles.event?.(pubkey) : undefined), - ); - const head = view && pubkey ? directoryHead : undefined; - const latest = events.events - .filter( - (event) => - event.kind === 0 && - event.pubkey === pubkey && - event.delivery !== "failed", - ) - .reduce(newer, head); - const [verified, setVerified] = useState<{ id: string; owner?: string }>(); - useEffect(() => { - if (!latest) return; - let active = true; - void attestedOwner(latest).then((owner) => { - if (active) setVerified({ id: latest.id, ...(owner ? { owner } : {}) }); - }); - return () => { - active = false; - }; - }, [latest]); - useEffect(() => { - if (events.status === "idle") void view?.refresh(); - }, [view, events.status]); - const settled = !!(verified && latest && verified.id === latest.id); - const owner = settled ? verified?.owner : undefined; - // Only an already admitted result from this observation may survive a - // background read. New heads, purges and failed reads must establish it anew. - const [admitted, setAdmitted] = useState<{ - view: ProfileView; - id: string; - }>(); - useEffect(() => { - setAdmitted((previous) => { - if ( - events.status === "ready" && - view && - latest && - latest.id === verified?.id - ) { - return previous?.view === view && previous.id === latest.id - ? previous - : { view, id: latest.id }; - } - return events.status === "loading" && - previous?.view === view && - previous?.id === latest?.id - ? previous - : undefined; - }); - }, [events.status, view, latest, verified]); - const refreshingAdmittedHead = - !!admitted && - events.status === "loading" && - admitted?.view === view && - admitted?.id === latest?.id; - const status = - view === null - ? "unavailable" - : events.status === "error" - ? "error" - : !view || - (events.status !== "ready" && !refreshingAdmittedHead) || - (latest && verified?.id !== latest.id) - ? "loading" - : "ready"; - return { - status, - owner, - failed: !!pubkey && (view === null || events.status === "error"), - // An absent event is conclusive only after its read completed. Failure or - // exhausted capacity must not unlock unverified legacy fallback. - settled: - !pubkey || settled || (!!view && !latest && events.status === "ready"), - }; -} - export function ProfileAgentIdentity({ session, owner, diff --git a/src/bundled/profiles/ProfilePanel.tsx b/src/bundled/profiles/ProfilePanel.tsx index 34b73acd0..9f70c2124 100644 --- a/src/bundled/profiles/ProfilePanel.tsx +++ b/src/bundled/profiles/ProfilePanel.tsx @@ -1,3 +1,4 @@ +import { useAgentOwnerEvidence } from "../../features/profiles/useAgentOwnerEvidence"; import { useAgentControlRefresh } from "../../features/agents/control-react"; import { UserStatusDisplay } from "../../features/user-status/StatusDisplay"; import { @@ -45,10 +46,7 @@ import { selectProfiles } from "../../features/relay/profile-selection"; import { useRelayConnection } from "../../features/relay/react"; import type { RelayData } from "../../features/relay/service"; import type { RelaySession } from "../../features/relay/session"; -import { - ProfileAgentIdentity, - useAgentOwnerEvidence, -} from "./ProfileAgentIdentity"; +import { ProfileAgentIdentity } from "./ProfileAgentIdentity"; import styles from "./Profiles.module.css"; const emptyState: AgentControlState = { diff --git a/src/features/messages/ChannelTimeline.restore.test.tsx b/src/features/messages/ChannelTimeline.restore.test.tsx index 08fb9aaae..d0196d143 100644 --- a/src/features/messages/ChannelTimeline.restore.test.tsx +++ b/src/features/messages/ChannelTimeline.restore.test.tsx @@ -130,6 +130,13 @@ function mount(bottom = false) { tree({ ...window, rows: [...window.rows, sent] }, sent.id), ); }, + replaceAnchor() { + const first = window.rows[0]; + if (!first) throw new Error("Missing fixture row"); + result.rerender( + tree({ ...window, rows: [{ ...first, id: "replacement" }] }), + ); + }, promote() { result.rerender( tree({ ...window, freshness: "verified", rows: [...window.rows] }), @@ -213,3 +220,73 @@ it.each([false, true])( ); }, ); + +it.each([false, true])( + "does not turn cold clamps into reader follow intent, repeated=%s", + async (repeat) => { + const h = mount(); + await frame(); + const feed = screen.getByRole("region", { + name: "Channel message history", + }); + // Cold estimated row heights can clamp the restoring viewport to the bottom. + // The saved row is mounted, so this is not the anchorless-range case above. + let rowY = 84; + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function (this: HTMLElement) { + return new DOMRect(0, this.closest("ol") ? rowY : 0, 800, 100); + }, + ); + feed.scrollTop = 1400; + fireEvent.scroll(feed); + if (repeat) { + feed.scrollTop = 1200; + rowY = 284; + fireEvent.scroll(feed); + feed.scrollTop = 1400; + rowY = 84; + fireEvent.scroll(feed); + } + scroll.toIndex.mockClear(); + h.promote(); + await frame(); + expect(scroll.toIndex).toHaveBeenLastCalledWith(0, { + align: "start", + offset: -42, + }); + h.unmount(); + expect(readView("scope", "scroll:c", null)).toEqual(h.saved); + }, +); + +it.each(["converged", "gesture", "removed"])( + "allows bottom follow after restoration is superseded: %s", + async (boundary) => { + const h = mount(); + await frame(); + const feed = screen.getByRole("region", { + name: "Channel message history", + }); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function (this: HTMLElement) { + return new DOMRect(0, this.closest("ol") ? 42 : 0, 800, 100); + }, + ); + if (boundary === "converged") { + feed.scrollTop = 900; + fireEvent.scroll(feed); + } else if (boundary === "gesture") { + fireEvent.pointerDown(feed); + } else { + h.replaceAnchor(); + await frame(); + } + feed.scrollTop = 1400; + fireEvent.scroll(feed); + h.unmount(); + expect( + readView<{ bottom: boolean }>("scope", "scroll:c", { bottom: false }) + .bottom, + ).toBe(true); + }, +); diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx index 0427ee8ed..31d6b8537 100644 --- a/src/features/messages/ChannelTimeline.tsx +++ b/src/features/messages/ChannelTimeline.tsx @@ -126,7 +126,7 @@ function Timeline({ readView(scope, `scroll:${channelId}`, null), ); const savedPosition = useRef(initialPosition); - const restoredAnchor = useRef(undefined); + const restoredAnchor = useRef(undefined); const rows = useMemo(() => membershipRows(window.rows), [window.rows]); const resolveName = useChannelIdentityNames(queries, channelId); const profiles = useRowProfiles(queries.profiles, window.rows); @@ -187,8 +187,8 @@ function Timeline({ const renderedAnchor = anchor ? rows.find( (row) => - row.id === anchor || - row.membershipRows?.some((member) => member.id === anchor), + row.id === anchor.id || + row.membershipRows?.some((member) => member.id === anchor.id), )?.id : undefined; const position = positionAt(element, renderedAnchor); @@ -196,6 +196,17 @@ function Timeline({ // range. An anchorless observation must not erase the saved reading intent. // A reader gesture clears restoredAnchor before recording a new position. if (anchor && !position.anchor) return; + // Cold estimates can leave too little height to reach the saved row/Y. + // A bottom clamp with that row still below its target is restoration, + // not reader intent. Reachable positions and new gestures remain free. + if ( + anchor && + position.anchor && + position.anchor.id === renderedAnchor && + position.anchor.y > anchor.y + 1 && + element.scrollHeight - element.clientHeight - element.scrollTop <= 1 + ) + return; const previous = measuredPosition.current; // List shrinkage can clamp scrollTop upward without reader movement. An // upward offset beyond that clamp is input, including later events from @@ -353,8 +364,9 @@ function Timeline({ row.membershipRows?.some((member) => member.id === anchor.id), ) : -1; - if (anchor && index >= 0) { - restoredAnchor.current = rows[index]?.id; + const row = rows[index]; + if (anchor && row) { + restoredAnchor.current = { id: row.id, y: anchor.y }; handle.current.scrollToIndex(index, { align: "start", offset: -anchor.y, @@ -419,7 +431,7 @@ function Timeline({ restore.anchor && restoredAnchor.current ? { ...restore, - anchor: { ...restore.anchor, id: restoredAnchor.current }, + anchor: { ...restore.anchor, id: restoredAnchor.current.id }, } : restore; settled.current = false; diff --git a/src/features/profiles/AgentOwnerPreview.test.tsx b/src/features/profiles/AgentOwnerPreview.test.tsx new file mode 100644 index 000000000..09557ed5a --- /dev/null +++ b/src/features/profiles/AgentOwnerPreview.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { createHash } from "node:crypto"; +import { schnorr } from "@noble/curves/secp256k1.js"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import { npubEncode } from "nostr-tools/nip19"; +import { bytesToHex } from "nostr-tools/utils"; +import { createRelaySession } from "../relay/session"; +import { keypair, profile, signed } from "../relay/testing"; +import { matchesEvent } from "../relay/projection"; +import { AgentOwnerPreview } from "./AgentOwnerPreview"; + +const stops: (() => void)[] = []; +afterEach(() => { + cleanup(); + for (const stop of stops.splice(0)) stop(); +}); + +it.each([true, false])( + "shows only a verified owner, not a profile claim (valid: %s)", + async (valid) => { + const agent = keypair(); + const owner = keypair(); + const other = keypair(); + const digest = new Uint8Array( + createHash("sha256").update(`nostr:agent-auth:${agent.pubkey}:`).digest(), + ); + const events = [ + signed(agent, { + kind: 0, + content: JSON.stringify({ name: "Helper", owner: owner.pubkey }), + tags: [ + [ + "auth", + owner.pubkey, + "", + bytesToHex( + schnorr.sign(digest, valid ? owner.secret : other.secret), + ), + ], + ], + }), + profile(owner, { name: "Morgan" }), + ]; + const host = createRelaySession({ + viewer: owner.pubkey, + relayAuthor: keypair().pubkey, + scope: "https://relay.example.test", + media: () => undefined, + query: async (filters) => + events.filter((event) => + filters.some((filter) => matchesEvent(event, filter)), + ), + }); + stops.push(host.dispose); + render(); + if (valid) { + expect(await screen.findByText("Morgan (you)")).toBeVisible(); + expect(screen.getByText(npubEncode(owner.pubkey))).toBeVisible(); + } else { + expect(await screen.findByText("Owner unavailable")).toBeVisible(); + expect( + screen.queryByText(npubEncode(owner.pubkey)), + ).not.toBeInTheDocument(); + expect(screen.queryByText("Morgan (you)")).not.toBeInTheDocument(); + } + }, +); diff --git a/src/features/profiles/AgentOwnerPreview.tsx b/src/features/profiles/AgentOwnerPreview.tsx new file mode 100644 index 000000000..0ce41f627 --- /dev/null +++ b/src/features/profiles/AgentOwnerPreview.tsx @@ -0,0 +1,70 @@ +import { selectProfiles } from "../relay/profile-selection"; +import { useIdentityNames } from "../identity-names/react"; +import { useEffect, useMemo, useSyncExternalStore } from "react"; +import { npubEncode } from "nostr-tools/nip19"; +import { useAgentOwnerEvidence } from "./useAgentOwnerEvidence"; +import type { RelaySession } from "../relay/session"; +import { Avatar } from "../../shared/design-system/ui/Avatar"; +import { formatPublicKey } from "../../shared/identity/public-key"; + +/** Mounted only for an open preview; metadata is never ownership evidence. */ +export function AgentOwnerPreview({ + session, + pubkey, +}: { + session: RelaySession; + pubkey: string; +}) { + const { owner, status } = useAgentOwnerEvidence(session, pubkey); + const selection = useMemo( + () => selectProfiles(session.profiles, owner ? [owner] : []), + [session.profiles, owner], + ); + const profiles = useSyncExternalStore( + selection.subscribe, + selection.snapshot, + selection.snapshot, + ); + const identityName = useIdentityNames(session.names); + useEffect(() => { + if (owner) + void session.profiles.ensure([owner], "background").catch(() => {}); + }, [session, owner]); + const profile = owner ? profiles.get(owner) : undefined; + const name = owner + ? identityName(owner, profile?.name ?? formatPublicKey(owner) ?? "Owner") + : undefined; + return ( +
    +
    Managed by
    + {owner ? ( + <> +
    + + + {name} + {owner === session.viewer ? " (you)" : ""} + +
    +
    + {npubEncode(owner)} +
    + + ) : ( +
    + {status === "loading" ? "Checking ownership…" : "Owner unavailable"} +
    + )} +
    + ); +} diff --git a/src/features/profiles/useAgentOwnerEvidence.ts b/src/features/profiles/useAgentOwnerEvidence.ts new file mode 100644 index 000000000..aee4efa27 --- /dev/null +++ b/src/features/profiles/useAgentOwnerEvidence.ts @@ -0,0 +1,136 @@ +import { useEffect, useState, useSyncExternalStore } from "react"; +import { attestedOwner } from "../agents/owner-attestation"; +import { type EventData, newer } from "../relay/events"; +import type { EventViewSnapshot, RelaySession } from "../relay/session"; + +type ProfileView = ReturnType; +const noSubscribe = () => () => {}; +const pendingView = (): EventViewSnapshot => pendingSnapshot; +const pendingSnapshot: EventViewSnapshot = Object.freeze({ + status: "loading", + events: [], +}); + +/** Verified NIP-OA owner of the winning signed kind 0, or none. Agent hints + * decide whether to mount this view; they never establish ownership. + * Private admission must inspect readiness as well as signed-head ownership. + * Public identity attribution may still display its retained signed evidence. */ +export function useAgentOwnerEvidence( + session: RelaySession, + pubkey: string | undefined, + attempt = 0, +): { + status: "loading" | "ready" | "error" | "unavailable"; + owner: string | undefined; + failed: boolean; + settled: boolean; +} { + // A session-owned view: live events, reconnect refresh and purge, no polling. + // Capacity or a closed session leaves no view; profile recovery retries. + const [view, setView] = useState(); + // biome-ignore lint/correctness/useExhaustiveDependencies: attempt retries view admission. + useEffect(() => { + if (!pubkey) { + setView(null); + return; + } + let owned: ProfileView; + try { + owned = session.observe([{ kinds: [0], authors: [pubkey], limit: 1 }]); + } catch { + setView(null); + return; + } + setView(owned); + return owned.dispose; + }, [session, pubkey, attempt]); + const events = useSyncExternalStore( + view?.subscribe ?? noSubscribe, + view?.snapshot ?? pendingView, + view?.snapshot ?? pendingView, + ); + // Provenance belongs to the winning signed kind 0 alone, never a display projection. + // The directory's retained head outlives this view, so a reopened pane cannot + // accept an older response than the profile the rest of the session shows. + // Subscribed, so any head change (live, read or disk restore) re-renders. + // Without a live view nothing can signal an auth-only change, so show nothing. + const directoryHead = useSyncExternalStore( + session.profiles.subscribe, + () => (pubkey ? session.profiles.event?.(pubkey) : undefined), + () => (pubkey ? session.profiles.event?.(pubkey) : undefined), + ); + const head = view && pubkey ? directoryHead : undefined; + const latest = events.events + .filter( + (event) => + event.kind === 0 && + event.pubkey === pubkey && + event.delivery !== "failed", + ) + .reduce(newer, head); + const [verified, setVerified] = useState<{ id: string; owner?: string }>(); + useEffect(() => { + if (!latest) return; + let active = true; + void attestedOwner(latest).then((owner) => { + if (active) setVerified({ id: latest.id, ...(owner ? { owner } : {}) }); + }); + return () => { + active = false; + }; + }, [latest]); + useEffect(() => { + if (events.status === "idle") void view?.refresh(); + }, [view, events.status]); + const settled = !!(verified && latest && verified.id === latest.id); + const owner = settled ? verified?.owner : undefined; + // Only an already admitted result from this observation may survive a + // background read. New heads, purges and failed reads must establish it anew. + const [admitted, setAdmitted] = useState<{ + view: ProfileView; + id: string; + }>(); + useEffect(() => { + setAdmitted((previous) => { + if ( + events.status === "ready" && + view && + latest && + latest.id === verified?.id + ) { + return previous?.view === view && previous.id === latest.id + ? previous + : { view, id: latest.id }; + } + return events.status === "loading" && + previous?.view === view && + previous?.id === latest?.id + ? previous + : undefined; + }); + }, [events.status, view, latest, verified]); + const refreshingAdmittedHead = + !!admitted && + events.status === "loading" && + admitted?.view === view && + admitted?.id === latest?.id; + const status = + view === null + ? "unavailable" + : events.status === "error" + ? "error" + : !view || + (events.status !== "ready" && !refreshingAdmittedHead) || + (latest && verified?.id !== latest.id) + ? "loading" + : "ready"; + return { + status, + owner, + failed: !!pubkey && (view === null || events.status === "error"), + // An absent event is conclusive only after its read completed. Failure or + // exhausted capacity must not unlock unverified legacy fallback. + settled: + !pubkey || settled || (!!view && !latest && events.status === "ready"), + }; +} diff --git a/src/shared/design-system/DESIGN.md b/src/shared/design-system/DESIGN.md index 0737df66e..6c56e028a 100644 --- a/src/shared/design-system/DESIGN.md +++ b/src/shared/design-system/DESIGN.md @@ -251,6 +251,11 @@ while the frame owns the active perimeter stroke. Read-only values can be read and copied; disabled actions cannot change a value. Search clear restores input focus. Features still own filtering, custom values, and async recovery. +SearchField, Combobox.Control, and code Textarea default to no autocorrection, +capitalization, or spellcheck. Callers can override these defaults explicitly. +Ordinary Input and prose Textarea retain platform defaults. See the +[exact-text input audit](../../../docs/input-correction-audit.md) for remaining fields. + The Forms page in Just Design documents states, usage, and a form-in-dialog example. Review it with both themes, narrow widths, and enlarged text before introducing another form treatment. @@ -369,6 +374,18 @@ offers an `option` variant for picker rows with even 8px padding and immediate hover feedback. It forwards normal button events, refs and data attributes so unread observation, preloading and product shortcuts remain with the caller. +PreviewCard may expose one supplemental action through `actionRef`, such as copying +an identity's full npub. Action previews open without a delay so immediate Tab +navigation reaches the action. They remain non-modal and never take focus on hover. Tab +from the trigger reaches the action; Shift+Tab returns to the trigger; forward Tab +continues after the trigger. Escape dismisses the preview before restoring focus, +but never pulls focus back if the user moved it during exit. Closing previews +are no longer Tab destinations. +The positioned portal owns its layer above dialogs. An optional content anchor +keeps previews near compact identity content inside wider actionable rows. Identity +previews prefer above that content (with Base UI collision handling), leaving +the hovered row’s trailing action unobstructed. + ## Menu row corners Every shared menu item uses `--radius-pill` on all four corners. First, middle and diff --git a/src/shared/design-system/styles/components.css b/src/shared/design-system/styles/components.css index a800ca6d1..334cef326 100644 --- a/src/shared/design-system/styles/components.css +++ b/src/shared/design-system/styles/components.css @@ -252,7 +252,6 @@ /* A preview is supplemental context; destination cards can also open it. It is portal-rendered by Base UI so a clipped parent cannot hide it. */ .buzz-preview-card { - z-index: var(--layer-popover); display: grid; width: max-content; max-width: min(18rem, 75vw); @@ -266,8 +265,12 @@ transform var(--duration-state) var(--easing-state); } - .buzz-preview-card[data-destination] { + .buzz-preview-card[data-destination], + .buzz-preview-card[data-interactive] { pointer-events: auto; + } + + .buzz-preview-card[data-destination] { cursor: pointer; color: inherit; text-decoration: none; @@ -819,7 +822,8 @@ @layer components { /* The positioned portal owns the stacking context, not its popup child. */ .buzz-menu-positioner, - .buzz-popover-positioner { + .buzz-popover-positioner, + .buzz-preview-card-positioner { z-index: var(--layer-popover); } .buzz-menu-popup { diff --git a/src/shared/design-system/ui/ChoiceControls.test.tsx b/src/shared/design-system/ui/ChoiceControls.test.tsx index 44ea6df5f..5fd4611da 100644 --- a/src/shared/design-system/ui/ChoiceControls.test.tsx +++ b/src/shared/design-system/ui/ChoiceControls.test.tsx @@ -338,3 +338,29 @@ it("keeps compact choices labelled and opens by click and keyboard", async () => expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); expect(trigger).toHaveFocus(); }); + +it("defaults combobox queries to exact text while permitting explicit correction", () => { + const view = render( + + + , + ); + const input = screen.getByRole("combobox", { name: "Query" }); + expect(input).toHaveAttribute("autocorrect", "off"); + expect(input).toHaveAttribute("autocapitalize", "none"); + expect(input).toHaveAttribute("spellcheck", "false"); + view.rerender( + + + , + ); + expect(input).toHaveAttribute("autocorrect", "on"); + expect(input).toHaveAttribute("autocapitalize", "sentences"); + expect(input).toHaveAttribute("spellcheck", "true"); +}); diff --git a/src/shared/design-system/ui/Combobox.tsx b/src/shared/design-system/ui/Combobox.tsx index 338a6c443..14e3d462c 100644 --- a/src/shared/design-system/ui/Combobox.tsx +++ b/src/shared/design-system/ui/Combobox.tsx @@ -69,6 +69,9 @@ function Control({ } > ; }; /** @@ -29,51 +39,109 @@ export function PreviewCard({ open, onOpenChange, side = "bottom", + anchor, delay = 250, className, + id, link, + actionRef, "aria-label": label, }: PreviewCardProps) { const triggerRef = useRef(null); const popupRef = useRef(null); + const focusedTrigger = useRef(null); + const closingPopup = useRef(null); + const restoreFocus = () => { + const trigger = triggerRef.current; + if (!trigger) return; + // Pointer entry may never focus a child of a non-focusable trigger wrapper. + const candidates = [ + focusedTrigger.current, + trigger, + ...trigger.querySelectorAll( + "button, a[href], input, select, textarea, [tabindex]", + ), + ]; + for (const candidate of candidates) { + if (!candidate || !trigger.contains(candidate)) continue; + candidate.focus(); + if (document.activeElement === candidate) return; + } + }; return ( - + { + onOpenChange?.(next, details); + if (details.isCanceled) return; + closingPopup.current = + !next && + details.reason === "escape-key" && + popupRef.current?.contains(document.activeElement) + ? popupRef.current + : null; + }} + onOpenChangeComplete={(next) => { + if (next) return; + // Retain the closing element through unmount, as finalFocus.ts does. + // Escape grants return-focus ownership only until the user moves it. + const popup = closingPopup.current; + closingPopup.current = null; + const active = popup?.ownerDocument.activeElement ?? null; + if ( + popup && + (active === popup.ownerDocument.body || popup.contains(active)) + ) + restoreFocus(); + }} + > { + focusedTrigger.current = event.target as HTMLElement; + }} onKeyDown={(event) => { if ( - link && + (link || actionRef) && event.key === "Tab" && !event.shiftKey && - popupRef.current + popupRef.current?.hasAttribute("data-open") ) { event.preventDefault(); - popupRef.current.focus(); + (actionRef?.current ?? popupRef.current).focus(); } }} /> - + { - if (link && event.key === "Tab") { + if ((link || actionRef) && event.key === "Tab") { // The portal is at the end of the document. Resume from its // trigger so Tab order follows the link's position in prose. - triggerRef.current?.focus(); + restoreFocus(); if (event.shiftKey) event.preventDefault(); } - if (link && event.key === "Escape") triggerRef.current?.focus(); }} className={["buzz-preview-card", className] .filter(Boolean) .join(" ")} render={link} - role={link ? "link" : "tooltip"} + role={link ? "link" : actionRef ? "dialog" : "tooltip"} + aria-modal={actionRef ? false : undefined} + data-interactive={actionRef ? "" : undefined} tabIndex={link ? 0 : undefined} data-destination={link ? "" : undefined} aria-label={label} diff --git a/src/shared/design-system/ui/SearchField.tsx b/src/shared/design-system/ui/SearchField.tsx index 145f07874..3c3800480 100644 --- a/src/shared/design-system/ui/SearchField.tsx +++ b/src/shared/design-system/ui/SearchField.tsx @@ -45,6 +45,9 @@ export function SearchField({ ) : null; const input = ( } + render={ +