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
2 changes: 2 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
"overrides": [
{
"includes": ["web/src/components/ds/**"],
"formatter": { "enabled": false },
"assist": { "actions": { "source": { "organizeImports": "off" } } },
"linter": {
"rules": {
"correctness": { "noUnusedImports": "off" },
Expand Down
15 changes: 15 additions & 0 deletions web/src/components/ds/core/CommandMenu.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export interface CommandMenuItem {
label: string
snippet?: string
keywords?: string
shortcut?: string
}
export interface CommandMenuProps {
items?: CommandMenuItem[]
placeholder?: string
onPick?: (item: CommandMenuItem) => void
inline?: boolean
width?: number
initialQuery?: string
}
export declare function CommandMenu(props: CommandMenuProps): JSX.Element

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does @types/react19.2.4 expose a global JSX namespace, or should library declaration files import JSX fromreactand useJSX.Element?

💡 Result:

No, @types/react 19.2.4 does not expose a global JSX namespace [1][2]. The global JSX namespace was removed in @types/react version 19 to prevent type pollution and conflicts with other UI libraries that also utilize JSX [3][4][1]. For library declaration files and general TypeScript code, you should import the JSX namespace directly from React and use React.JSX.Element instead of the previously global JSX.Element [1][5]. You can access it by importing: import { JSX } from 'react'; // Use as const myElement: JSX.Element =

; If you need to perform module augmentation to add intrinsic elements, you can no longer use declare global [6][5]. Instead, you must target the specific React module based on your compiler's JSX runtime setting [3][4][6]: declare module 'react' { namespace JSX { interface IntrinsicElements { 'my-element': { myElementProps: string }; } } } The exact module name to augment depends on your tsconfig.json configuration [3][4]: - For "jsx": "react-jsx", use 'react/jsx-runtime'. - For "jsx": "react-jsxdev", use 'react/jsx-dev-runtime'. - For "jsx": "react" or "preserve", use 'react'.

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file='web/src/components/ds/core/CommandMenu.d.ts'

echo '--- file outline ---'
wc -l "$file"
echo
cat -n "$file"

echo
echo '--- search for JSX imports/usages in nearby ds core declarations ---'
rg -n --hidden --glob 'web/src/components/ds/core/*.{d.ts,ts,tsx,js,jsx}' 'from '\''react'\''|namespace JSX|JSX\.Element|import type \{ JSX \}' .

Repository: Helpthread/helpthread

Length of output: 4929


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- package.json ---'
if [ -f package.json ]; then
  cat -n package.json | sed -n '1,240p'
fi

echo
echo '--- tsconfig files mentioning jsx or types ---'
for f in $(git ls-files '*tsconfig*.json' 'package.json' 'pnpm-lock.yaml' 'yarn.lock' 'package-lock.json' 2>/dev/null); do
  case "$f" in
    *tsconfig*.json|package.json)
      echo "### $f"
      cat -n "$f" | sed -n '1,240p'
      echo
      ;;
  esac
done

echo '--- any JSX namespace declarations in repo ---'
rg -n --hidden --glob '!**/node_modules/**' 'declare\s+namespace\s+JSX|namespace\s+JSX|interface\s+IntrinsicElements' .

Repository: Helpthread/helpthread

Length of output: 4990


Reference React’s scoped JSX namespace.
web/src/components/ds/core/CommandMenu.d.ts:15 should not depend on the ambient global JSX namespace. Import type { JSX } from 'react' and keep the return type as JSX.Element so this public declaration stays compatible with React 19 consumers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/ds/core/CommandMenu.d.ts` at line 15, Update the
CommandMenu declaration to import the JSX type namespace from React and retain
JSX.Element as the return type of CommandMenu, removing its reliance on the
ambient global JSX namespace.

225 changes: 225 additions & 0 deletions web/src/components/ds/core/CommandMenu.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import React from 'react'
import { IconReply, IconSearch } from './primitives-support'

/** Searchable inserter for saved replies. Filters as you type;
* ↑↓ moves, ↵ inserts, esc clears. */
export function CommandMenu({
items = [],
placeholder = 'Search saved replies…',
onPick,
inline,
width = 320,
initialQuery = '',
}) {
const [q, setQ] = React.useState(initialQuery)
const [hi, setHi] = React.useState(0)
const inputRef = React.useRef(null)
const listRef = React.useRef(null)

const filtered = React.useMemo(() => {
const s = q.trim().toLowerCase()
if (!s) return items
return items.filter((it) =>
`${it.label} ${it.snippet || ''} ${it.keywords || ''}`.toLowerCase().includes(s),
)
}, [q, items])

// `q` is the trigger here, not a read: the highlight resets to the top of the
// list whenever the query changes.
// biome-ignore lint/correctness/useExhaustiveDependencies: q is a trigger, not a read
React.useEffect(() => {
setHi(0)
}, [q])

React.useEffect(() => {
const el = listRef.current?.children[hi]
if (el?.scrollIntoViewIfNeeded) {
el.scrollIntoViewIfNeeded()
} else if (el) {
const p = listRef.current
if (el.offsetTop < p.scrollTop) p.scrollTop = el.offsetTop
else if (el.offsetTop + el.offsetHeight > p.scrollTop + p.clientHeight)
p.scrollTop = el.offsetTop + el.offsetHeight - p.clientHeight
}
}, [hi])

const key = (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setHi((i) => Math.min(i + 1, filtered.length - 1))
Comment on lines +47 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep the highlighted index non-negative for empty results.

Line 49 sets hi to -1 when filtered is empty. If items later arrive without changing q, the menu has no highlighted row and Enter cannot select one.

Proposed fix
-      setHi((i) => Math.min(i + 1, filtered.length - 1))
+      setHi((i) => Math.max(0, Math.min(i + 1, filtered.length - 1)))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (e.key === 'ArrowDown') {
e.preventDefault()
setHi((i) => Math.min(i + 1, filtered.length - 1))
if (e.key === 'ArrowDown') {
e.preventDefault()
setHi((i) => Math.max(0, Math.min(i + 1, filtered.length - 1)))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/ds/core/CommandMenu.jsx` around lines 47 - 49, Update the
ArrowDown handling in the CommandMenu highlight state update to clamp the
highlighted index at zero when filtered is empty, while preserving the existing
upper bound for non-empty results. Ensure hi remains non-negative so
later-arriving items can be highlighted and selected.

} else if (e.key === 'ArrowUp') {
e.preventDefault()
setHi((i) => Math.max(i - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
const it = filtered[hi]
if (it) onPick?.(it)
} else if (e.key === 'Escape') {
e.preventDefault()
setQ('')
e.target.blur()
}
}

return (
<div
style={{
width,
background: 'var(--ht-surface)',
border: '1px solid var(--ht-border)',
borderRadius: 'var(--ht-radius-md)',
boxShadow: 'var(--ht-shadow-md)',
overflow: 'hidden',
animation: inline ? 'none' : 'ht-rise .16s ease-out',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '9px 12px',
borderBottom: '1px solid var(--ht-divider)',
}}
>
<span style={{ display: 'inline-flex', color: 'var(--ht-ink-dim)' }}>{IconSearch(15)}</span>
<input
ref={inputRef}
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={key}
placeholder={placeholder}
// The menu opens in response to an explicit user action and the search
// field is its entire purpose; not focusing it would strand keyboard users.
// biome-ignore lint/a11y/noAutofocus: deliberate in the design source
autoFocus={!inline}
style={{
flex: 1,
font: 'inherit',
fontFamily: 'var(--ht-sans)',
fontSize: 13.5,
color: 'var(--ht-ink)',
background: 'none',
border: 'none',
outline: 'none',
padding: 0,
}}
/>
<span
style={{
fontFamily: 'var(--ht-mono)',
fontSize: 11,
color: 'var(--ht-ink-dim)',
border: '1px solid var(--ht-border)',
borderBottomWidth: 2,
borderRadius: 'var(--ht-radius-sm)',
padding: '1px 6px',
}}
>
esc
</span>
</div>
{filtered.length === 0 ? (
<div style={{ padding: '34px 20px 30px', textAlign: 'center' }}>
<div style={{ fontSize: 14.5, fontWeight: 600, color: 'var(--ht-ink)' }}>
No matching replies
</div>
<div
style={{
margin: '6px auto 0',
maxWidth: 220,
fontSize: 12.5,
lineHeight: 1.5,
color: 'var(--ht-ink-muted)',
}}
>
{`Nothing matches “${q}”. Try a shorter term.`}
</div>
</div>
) : (
<div ref={listRef} style={{ maxHeight: 244, overflowY: 'auto', padding: 5 }}>
{filtered.map((it, i) => (
<button
key={it.label}
type="button"
onClick={() => onPick?.(it)}
onMouseMove={() => setHi(i)}
style={{
display: 'block',
width: '100%',
boxSizing: 'border-box',
textAlign: 'left',
border: 'none',
cursor: 'pointer',
borderRadius: 'var(--ht-radius-sm)',
padding: '7px 10px',
background: i === hi ? 'var(--ht-accent-soft)' : 'transparent',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span
style={{
display: 'inline-flex',
color: i === hi ? 'var(--ht-accent)' : 'var(--ht-ink-dim)',
}}
>
{IconReply(14)}
</span>
<span
style={{
flex: 1,
fontSize: 13,
fontWeight: 600,
color: i === hi ? 'var(--ht-accent)' : 'var(--ht-ink)',
}}
>
{it.label}
</span>
{it.shortcut ? (
<span
style={{
fontFamily: 'var(--ht-mono)',
fontSize: 11,
color: 'var(--ht-ink-dim)',
}}
>
{it.shortcut}
</span>
) : null}
</div>
{it.snippet ? (
<div
style={{
margin: '2px 0 0 24px',
fontSize: 12,
lineHeight: 1.4,
color: 'var(--ht-ink-muted)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{it.snippet}
</div>
) : null}
</button>
))}
</div>
)}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 14,
padding: '7px 12px',
borderTop: '1px solid var(--ht-divider)',
fontSize: 11,
color: 'var(--ht-ink-dim)',
}}
>
<span>{`${filtered.length} ${filtered.length === 1 ? 'reply' : 'replies'}`}</span>
<span style={{ marginLeft: 'auto', fontFamily: 'var(--ht-mono)' }}>↑↓ move · ↵ insert</span>
</div>
</div>
)
}
21 changes: 21 additions & 0 deletions web/src/components/ds/core/CredentialRow.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export interface Credential {
name: string
added: Date
lastUsed?: Date | null
}
export interface CredentialRowProps {
cred: Credential
/** Forces a visual state for specimen rendering. */
demo?: 'hover' | 'rename' | 'armed'
onRename?: (cred: Credential, name: string) => void
onRevoke?: (cred: Credential) => void
first?: boolean
}
export declare function CredentialRow(props: CredentialRowProps): JSX.Element

export interface PasskeyListProps {
creds?: Credential[]
empty?: boolean
onAdd?: () => void
}
export declare function PasskeyList(props: PasskeyListProps): JSX.Element
Loading