Add emoji picker to workspace editing and search (#13429)

* Add emoji picker to workspace editing and search

* fix(sidebar): scope sticky headers by host

* fix: address emoji picker review findings
This commit is contained in:
Neil 2026-08-09 20:25:15 -07:00 committed by GitHub
parent 6e63bbbb52
commit 9ccec550c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 688 additions and 92 deletions

View File

@ -69,6 +69,8 @@ vi.mock('@/lib/workspace-tab-palette-activation', () => ({
vi.mock('@/components/ui/command', async () => {
const React = await import('react')
return {
Command: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CommandGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
// Why the commandProps passthrough: cmdk resolves Enter against its `value`, so the controlled
// value is the only honest stand-in for "what would Enter activate" without mounting real cmdk.
CommandDialog: ({

View File

@ -2,6 +2,7 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { fireEvent } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ReactI18Next from 'react-i18next'
import { useAppStore } from '@/store'
@ -74,25 +75,40 @@ vi.mock('@/components/ui/command', async () => {
</div>
) : null
},
CommandInput: ({
value,
onValueChange,
placeholder
}: {
value?: string
onValueChange?: (next: string) => void
placeholder?: string
}) => {
Command: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CommandGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CommandInput: React.forwardRef(function CommandInput(
{
value,
onValueChange,
placeholder,
onClick,
onSelect,
onKeyDown
}: {
value?: string
onValueChange?: (next: string) => void
placeholder?: string
onClick?: React.MouseEventHandler<HTMLInputElement>
onSelect?: React.ReactEventHandler<HTMLInputElement>
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>
},
ref: React.ForwardedRef<HTMLInputElement>
) {
setCommandQuery = onValueChange ?? null
return (
<input
ref={ref}
data-command-input="true"
placeholder={placeholder}
value={value}
onChange={(event) => onValueChange?.(event.currentTarget.value)}
onClick={onClick}
onSelect={onSelect}
onKeyDown={onKeyDown}
/>
)
},
}),
CommandList: React.forwardRef(function CommandList(
{ children }: { children: React.ReactNode },
ref: React.ForwardedRef<HTMLDivElement>
@ -337,4 +353,16 @@ describe('WorktreeJumpPalette', () => {
expect(testContainer.textContent).toContain('Feature workspace')
})
it('replaces a completed emoji shortcode in the search query', async () => {
await renderPalette({ worktreesByRepo: { 'repo-1': [] } })
const input = testContainer.querySelector<HTMLInputElement>('[data-command-input="true"]')
expect(input).not.toBeNull()
await act(async () => {
fireEvent.change(input!, { target: { value: ':wink:', selectionStart: 6 } })
})
expect(input?.value).toBe('😉')
})
})

View File

@ -169,6 +169,8 @@ import { resolvePaletteFocusRestoreTarget } from '@/components/cmd-j/palette-foc
import { selectWorktreePaletteCacheInputs } from '@/components/cmd-j/worktree-palette-cache-inputs'
import { getRepoHostIdentity } from '@/store/slices/repo-host-identity'
import { buildPluginQuickActions } from '@/components/cmd-j/plugin-quick-actions'
import { WorkspaceEmojiSuggestionPopover } from '@/components/workspace-emoji/WorkspaceEmojiSuggestionPopover'
import { useWorkspaceEmojiShortcodeInput } from '@/components/workspace-emoji/useWorkspaceEmojiShortcodeInput'
import { usePluginCommands } from '@/store/plugin-panels'
import {
getComposerEligibleRepos,
@ -1893,6 +1895,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
setSelectedItemId('')
listRef.current?.scrollTo(0, 0)
}, [])
const emojiInput = useWorkspaceEmojiShortcodeInput({
inputRef,
onValueChange: handleQueryChange,
value: query
})
const cancelFallbackFocusFrames = useCallback((): void => {
if (fallbackFocusOuterFrameRef.current !== null) {
@ -2455,7 +2462,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
'Search chats, terminals, worktrees, settings, and actions...'
)}
value={query}
onValueChange={handleQueryChange}
onValueChange={emojiInput.handleValueChange}
onClick={(event) => emojiInput.syncCursor(event.currentTarget)}
onSelect={(event) => emojiInput.syncCursor(event.currentTarget)}
onKeyDown={(event) => emojiInput.handleKeyDown(event)}
wrapperClassName="mx-3 mt-3 rounded-lg border border-border/55 bg-muted/28 px-3.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
iconClassName="mr-2.5 h-4 w-4 text-muted-foreground/60"
className="h-12 text-[14px] placeholder:text-muted-foreground/75"
@ -2471,6 +2481,19 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
</div>
}
/>
<WorkspaceEmojiSuggestionPopover
anchorRef={inputRef}
open={emojiInput.open}
commandValue={emojiInput.commandValue}
heading={translate('auto.components.new.workspace.SmartWorkspaceNameField.emoji', 'Emoji')}
suggestions={emojiInput.suggestions}
onCommandValueChange={emojiInput.onCommandValueChange}
onSelect={emojiInput.selectSuggestion}
onOpenChange={(open) => !open && emojiInput.close()}
portalContainer={dialogElement}
side="bottom"
contentClassName="w-80"
/>
<PaletteFilterChips model={filterModel} filter={filter} onFilterChange={setRawFilter} />
<CommandList
ref={listRef}

View File

@ -108,7 +108,7 @@ import {
type WorkspaceEmojiReplacement,
type WorkspaceEmojiSuggestion
} from '@/lib/workspace-emoji-shortcodes'
import { WorkspaceEmojiSuggestionPopover } from './WorkspaceEmojiSuggestionPopover'
import { WorkspaceEmojiSuggestionPopover } from '@/components/workspace-emoji/WorkspaceEmojiSuggestionPopover'
type RepoOption = ReturnType<typeof useAppStore.getState>['repos'][number]
const EMPTY_REPO_SEARCH_REPOS: readonly RepoOption[] = []

View File

@ -0,0 +1,79 @@
import { useId, type RefObject } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { WorkspaceEmojiSuggestionPopover } from '@/components/workspace-emoji/WorkspaceEmojiSuggestionPopover'
import { useWorkspaceEmojiShortcodeInput } from '@/components/workspace-emoji/useWorkspaceEmojiShortcodeInput'
import { translate } from '@/i18n/i18n'
type WorktreeDisplayNameFieldProps = {
disabled: boolean
inputRef: RefObject<HTMLInputElement | null>
onEnter: () => void | Promise<void>
onValueChange: (value: string) => void
portalContainer: HTMLElement | null
value: string
}
export function WorktreeDisplayNameField({
disabled,
inputRef,
onEnter,
onValueChange,
portalContainer,
value
}: WorktreeDisplayNameFieldProps): React.JSX.Element {
const inputId = useId()
const emojiInput = useWorkspaceEmojiShortcodeInput({
disabled,
inputRef,
onValueChange,
value
})
return (
<div className="space-y-1">
<Label htmlFor={inputId} className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.sidebar.WorktreeMetaDialog.ad5e4e514f', 'Display Name')}
</Label>
<Input
id={inputId}
ref={inputRef}
value={value}
onChange={(event) =>
emojiInput.handleValueChange(event.target.value, event.target.selectionStart)
}
onSelect={(event) => emojiInput.syncCursor(event.currentTarget)}
onKeyDown={(event) => {
if (emojiInput.handleKeyDown(event) || event.key !== 'Enter') {
return
}
event.preventDefault()
void onEnter()
}}
placeholder={translate(
'auto.components.sidebar.WorktreeMetaDialog.7f21e0464f',
'Custom display name...'
)}
className="h-8 text-xs"
/>
<WorkspaceEmojiSuggestionPopover
anchorRef={inputRef}
open={emojiInput.open}
commandValue={emojiInput.commandValue}
heading={translate('auto.components.new.workspace.SmartWorkspaceNameField.emoji', 'Emoji')}
suggestions={emojiInput.suggestions}
onCommandValueChange={emojiInput.onCommandValueChange}
onSelect={emojiInput.selectSuggestion}
onOpenChange={(open) => !open && emojiInput.close()}
portalContainer={portalContainer}
side="bottom"
/>
<p className="text-[10px] text-muted-foreground">
{translate(
'auto.components.sidebar.WorktreeMetaDialog.459ad7f650',
'Only changes the name shown in the sidebar — the folder on disk stays the same. Leave blank to use the branch or folder name.'
)}
</p>
</div>
)
}

View File

@ -225,6 +225,17 @@ describe('WorktreeMetaDialog issue link row', () => {
expect(issueInput().value).toBe('42')
})
it('replaces a completed emoji shortcode in the display name', () => {
openDialog()
const displayNameInput = screen.getByRole('textbox', { name: 'Display Name' })
fireEvent.change(displayNameInput, {
target: { value: 'Feature :wink:', selectionStart: 14 }
})
expect((displayNameInput as HTMLInputElement).value).toBe('Feature 😉')
})
it('seeds the chip and value from a Linear link', () => {
openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })

View File

@ -30,6 +30,7 @@ import {
parseIssueLinkInput,
type IssueLinkProvider
} from '../../../../shared/issue-link-input'
import { WorktreeDisplayNameField } from './WorktreeDisplayNameField'
function resizeCommentTextarea(textarea: HTMLTextAreaElement): void {
textarea.style.height = 'auto'
@ -93,6 +94,7 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [snapshot, setSnapshot] = useState<WorktreeMetaSnapshot>(EMPTY_SNAPSHOT)
const [dialogElement, setDialogElement] = useState<HTMLElement | null>(null)
const { canOpenIssue, openingIssue, openIssueFailed, handleOpenIssue, resetOpeningIssue } =
useWorktreeIssueLink({
worktreeId,
@ -282,6 +284,7 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent
ref={setDialogElement}
className="max-w-md"
onOpenAutoFocus={(e) => {
e.preventDefault()
@ -312,28 +315,14 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.sidebar.WorktreeMetaDialog.ad5e4e514f', 'Display Name')}
</label>
<Input
ref={displayNameInputRef}
value={displayNameInput}
onChange={(e) => setDisplayNameInput(e.target.value)}
onKeyDown={handleIssueKeyDown}
placeholder={translate(
'auto.components.sidebar.WorktreeMetaDialog.7f21e0464f',
'Custom display name...'
)}
className="h-8 text-xs"
/>
<p className="text-[10px] text-muted-foreground">
{translate(
'auto.components.sidebar.WorktreeMetaDialog.459ad7f650',
'Only changes the name shown in the sidebar — the folder on disk stays the same. Leave blank to use the branch or folder name.'
)}
</p>
</div>
<WorktreeDisplayNameField
disabled={saving}
inputRef={displayNameInputRef}
onEnter={handleSave}
onValueChange={setDisplayNameInput}
portalContainer={dialogElement}
value={displayNameInput}
/>
<WorktreeIssueLinkField
inputRef={issueInputRef}

View File

@ -59,6 +59,28 @@ vi.mock('@/components/ui/tooltip', () => ({
}
}))
vi.mock('@/components/workspace-emoji/WorkspaceEmojiSuggestionPopover', () => ({
WorkspaceEmojiSuggestionPopover: () => null
}))
vi.mock('@/components/workspace-emoji/useWorkspaceEmojiShortcodeInput', () => ({
useWorkspaceEmojiShortcodeInput: ({
onValueChange
}: {
onValueChange: (value: string) => void
}) => ({
close: vi.fn(),
commandValue: '',
handleKeyDown: () => false,
handleValueChange: (value: string) => onValueChange(value),
onCommandValueChange: vi.fn(),
open: false,
selectSuggestion: vi.fn(),
suggestions: [],
syncCursor: vi.fn()
})
}))
type ReactElementLike = {
type: unknown
props: Record<string, unknown>

View File

@ -3,6 +3,8 @@ import { LoaderCircle } from 'lucide-react'
import { toast } from 'sonner'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { WorkspaceEmojiSuggestionPopover } from '@/components/workspace-emoji/WorkspaceEmojiSuggestionPopover'
import { useWorkspaceEmojiShortcodeInput } from '@/components/workspace-emoji/useWorkspaceEmojiShortcodeInput'
import { cn } from '@/lib/utils'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { translate } from '@/i18n/i18n'
@ -68,10 +70,17 @@ export function WorktreeTitleInlineRename({
const titleElementRef = useRef<HTMLSpanElement | null>(null)
const titleResizeObserverRef = useRef<ResizeObserver | null>(null)
const removeTitleResizeListenerRef = useRef<(() => void) | null>(null)
const inputElementRef = useRef<HTMLInputElement | null>(null)
const [editing, setEditing] = useState(false)
const [value, setValue] = useState(displayName)
const [saving, setSaving] = useState(false)
const [titleTruncated, setTitleTruncated] = useState(false)
const emojiInput = useWorkspaceEmojiShortcodeInput({
disabled: saving,
inputRef: inputElementRef,
onValueChange: setValue,
value
})
const measureTitleTruncated = useCallback((element: HTMLSpanElement | null) => {
const nextTruncated = element ? isWorktreeTitleTruncated(element) : false
@ -141,6 +150,7 @@ export function WorktreeTitleInlineRename({
)
const handleInputRef = useCallback((input: HTMLInputElement | null) => {
inputElementRef.current = input
if (!input) {
return
}
@ -182,9 +192,10 @@ export function WorktreeTitleInlineRename({
)
const cancelRename = useCallback(() => {
emojiInput.close()
setValue(displayName)
setEditingMode(false)
}, [displayName, setEditingMode])
}, [displayName, emojiInput, setEditingMode])
const commitRename = useCallback(async () => {
if (savingRef.current) {
@ -226,6 +237,9 @@ export function WorktreeTitleInlineRename({
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
event.stopPropagation()
if (emojiInput.handleKeyDown(event)) {
return
}
// Why: an Enter that only confirms a CJK IME candidate must not commit the
// rename; wait for a non-composition Enter.
if (isImeCompositionKeyDown(event)) {
@ -239,61 +253,81 @@ export function WorktreeTitleInlineRename({
cancelRename()
}
},
[cancelRename, commitRename]
[cancelRename, commitRename, emojiInput]
)
if (editing) {
return (
<span
key={`editing:${titleElementKey}`}
ref={handleRootRef}
className={cn(
'relative grid min-w-0 truncate leading-tight text-foreground',
showUnreadEmphasis ? 'font-semibold' : 'font-normal',
className,
editingClassName
)}
data-worktree-title-inline-rename="editing"
>
<>
<span
className="invisible col-start-1 row-start-1 min-w-0 truncate whitespace-pre"
aria-hidden="true"
>
{displayName}
</span>
<Input
ref={handleInputRef}
value={value}
style={{ font: 'inherit' }}
disabled={saving}
spellCheck={false}
aria-label={translate(
'auto.components.sidebar.WorktreeTitleInlineRename.bff3bdd00c',
'Rename workspace'
)}
data-worktree-title-rename-input="true"
onChange={(event) => setValue(event.target.value)}
onBlur={() => void commitRename()}
onClick={stopCardEvent}
onDoubleClick={stopCardEvent}
onPointerDown={stopCardEvent}
onKeyDown={handleKeyDown}
key={`editing:${titleElementKey}`}
ref={handleRootRef}
className={cn(
'col-start-1 row-start-1 min-w-0 select-text truncate text-foreground outline-none',
editingInputClassName,
saving && savingInputClassName,
inputClassName
'relative grid min-w-0 truncate leading-tight text-foreground',
showUnreadEmphasis ? 'font-semibold' : 'font-normal',
className,
editingClassName
)}
/>
{saving ? (
<LoaderCircle
data-worktree-title-inline-rename="editing"
>
<span
className="invisible col-start-1 row-start-1 min-w-0 truncate whitespace-pre"
aria-hidden="true"
>
{displayName}
</span>
<Input
ref={handleInputRef}
value={value}
style={{ font: 'inherit' }}
disabled={saving}
spellCheck={false}
aria-label={translate(
'auto.components.sidebar.WorktreeTitleInlineRename.bff3bdd00c',
'Rename workspace'
)}
data-worktree-title-rename-input="true"
onChange={(event) =>
emojiInput.handleValueChange(event.target.value, event.target.selectionStart)
}
onSelect={(event) => emojiInput.syncCursor(event.currentTarget)}
onBlur={() => void commitRename()}
onClick={stopCardEvent}
onDoubleClick={stopCardEvent}
onPointerDown={stopCardEvent}
onKeyDown={handleKeyDown}
className={cn(
'pointer-events-none absolute top-1/2 size-3 -translate-y-1/2 animate-spin text-muted-foreground',
savingSpinnerClassName
'col-start-1 row-start-1 min-w-0 select-text truncate text-foreground outline-none',
editingInputClassName,
saving && savingInputClassName,
inputClassName
)}
/>
) : null}
</span>
{saving ? (
<LoaderCircle
className={cn(
'pointer-events-none absolute top-1/2 size-3 -translate-y-1/2 animate-spin text-muted-foreground',
savingSpinnerClassName
)}
/>
) : null}
</span>
<WorkspaceEmojiSuggestionPopover
anchorRef={inputElementRef}
open={emojiInput.open}
commandValue={emojiInput.commandValue}
heading={translate(
'auto.components.new.workspace.SmartWorkspaceNameField.emoji',
'Emoji'
)}
suggestions={emojiInput.suggestions}
onCommandValueChange={emojiInput.onCommandValueChange}
onSelect={emojiInput.selectSuggestion}
onOpenChange={(open) => !open && emojiInput.close()}
side="right"
contentClassName="w-56"
/>
</>
)
}

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { VirtualItem } from '@tanstack/react-virtual'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import {
HOST_STICKY_PINNED_HEIGHT,
buildLineageRowRekeyMap,
@ -11,11 +12,11 @@ import {
type RenderRow
} from './worktree-list-virtual-rows'
function hostRow(hostId: string): RenderRow {
function hostRow(hostId: ExecutionHostId): RenderRow {
return {
type: 'host-header',
key: `host:${hostId}`,
hostId: hostId as never,
hostId,
kind: 'ssh',
label: hostId,
detail: 'SSH',
@ -25,8 +26,15 @@ function hostRow(hostId: string): RenderRow {
}
}
function groupRow(key: string): RenderRow {
return { type: 'header', key, label: key, count: 1, tone: 'text-foreground' }
function groupRow(key: string, hostId?: ExecutionHostId): RenderRow {
return {
type: 'header',
key,
label: key,
count: 1,
tone: 'text-foreground',
...(hostId ? { hostId } : {})
}
}
function itemStub(id: string): RenderRow {
@ -39,11 +47,11 @@ function virtualItem(index: number, start: number): VirtualItem {
// rows: [host-a, group-a1, item, item, host-b, group-b1, item]
const rows: RenderRow[] = [
hostRow('a'),
hostRow('ssh:a'),
groupRow('a1'),
itemStub('wt-1'),
itemStub('wt-2'),
hostRow('b'),
hostRow('ssh:b'),
groupRow('b1'),
itemStub('wt-3')
]
@ -51,6 +59,23 @@ const stickyHeaderIndexes = getStickyHeaderIndexes(rows)
// Geometry: each row 100px tall for easy math.
const virtualItems = rows.map((_, index) => virtualItem(index, index * 100))
describe('getRenderRowKey', () => {
it('scopes repeated group headers to their host section', () => {
expect(getRenderRowKey(groupRow('workspace-status:in-progress', 'local'))).toBe(
'hdr:local:workspace-status:in-progress'
)
expect(getRenderRowKey(groupRow('workspace-status:in-progress', 'ssh:builder'))).toBe(
'hdr:ssh:builder:workspace-status:in-progress'
)
})
it('preserves unsectioned group header keys', () => {
expect(getRenderRowKey(groupRow('workspace-status:in-progress'))).toBe(
'hdr:workspace-status:in-progress'
)
})
})
describe('getActiveStickyIndexesForScroll', () => {
it('pins the host and its inner group while scrolled inside a section', () => {
expect(
@ -149,7 +174,7 @@ describe('getActiveStickyIndexesForScroll', () => {
it('keeps the previous mounted Project sticky when the next group is unmounted', () => {
const multiGroupRows: RenderRow[] = [
hostRow('a'),
hostRow('ssh:a'),
groupRow('a1'),
itemStub('wt-1'),
groupRow('a2'),
@ -263,7 +288,8 @@ describe('buildLineageRowRekeyMap', () => {
it('contributes nothing for non-lineage row types', () => {
expect(
buildLineageRowRekeyMap([hostRow('a'), groupRow('a1'), hostRow('b'), groupRow('b1')]).size
buildLineageRowRekeyMap([hostRow('ssh:a'), groupRow('a1'), hostRow('ssh:b'), groupRow('b1')])
.size
).toBe(0)
})

View File

@ -20,7 +20,7 @@ export function getRenderRowKey(row: RenderRow): string {
return `host:${row.hostId}`
}
if (row.type === 'header') {
return `hdr:${row.key}`
return row.hostId ? `hdr:${row.hostId}:${row.key}` : `hdr:${row.key}`
}
if (row.type === 'lineage-group') {
return `lineage-group:${row.key}`

View File

@ -1,27 +1,34 @@
import type { RefObject } from 'react'
import type { ComponentProps, RefObject } from 'react'
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import type { WorkspaceEmojiSuggestion } from '@/lib/workspace-emoji-shortcodes'
type WorkspaceEmojiSuggestionPopoverProps = {
anchorRef: RefObject<HTMLInputElement | null>
commandValue: string
contentClassName?: string
heading: string
onCommandValueChange: (value: string) => void
onOpenChange: (open: boolean) => void
onSelect: (suggestion: WorkspaceEmojiSuggestion) => void
open: boolean
portalContainer?: HTMLElement | null
side?: ComponentProps<typeof PopoverContent>['side']
suggestions: readonly WorkspaceEmojiSuggestion[]
}
export function WorkspaceEmojiSuggestionPopover({
anchorRef,
commandValue,
contentClassName,
heading,
onCommandValueChange,
onOpenChange,
onSelect,
open,
portalContainer,
side = 'top',
suggestions
}: WorkspaceEmojiSuggestionPopoverProps): React.JSX.Element {
return (
@ -30,11 +37,16 @@ export function WorkspaceEmojiSuggestionPopover({
<PopoverContent
data-workspace-emoji-suggestions="true"
align="start"
side="top"
side={side}
sideOffset={4}
avoidCollisions={false}
className="popover-scroll-content flex max-h-56 w-[var(--radix-popover-trigger-width)] flex-col p-0"
portalContainer={portalContainer}
className={cn(
'popover-scroll-content flex max-h-56 w-[var(--radix-popover-trigger-width)] flex-col p-0',
contentClassName
)}
onOpenAutoFocus={(event) => event.preventDefault()}
onPointerDown={(event) => event.preventDefault()}
onPointerDownOutside={(event) => {
if (anchorRef.current?.contains(event.target as Node)) {
event.preventDefault()

View File

@ -0,0 +1,122 @@
// @vitest-environment happy-dom
import { useRef, useState } from 'react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useWorkspaceEmojiShortcodeInput } from './useWorkspaceEmojiShortcodeInput'
function EmojiInputHarness({
initialValue,
onUnhandledKeyDown
}: {
initialValue: string
onUnhandledKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void
}): React.JSX.Element {
const [value, setValue] = useState(initialValue)
const inputRef = useRef<HTMLInputElement>(null)
const emojiInput = useWorkspaceEmojiShortcodeInput({
inputRef,
onValueChange: setValue,
value
})
return (
<div data-emoji-menu-open={emojiInput.open ? 'true' : 'false'}>
<input
ref={inputRef}
aria-label="Workspace name"
value={value}
onChange={(event) =>
emojiInput.handleValueChange(
event.currentTarget.value,
event.currentTarget.selectionStart
)
}
onSelect={(event) => emojiInput.syncCursor(event.currentTarget)}
onKeyDown={(event) => {
if (!emojiInput.handleKeyDown(event)) {
onUnhandledKeyDown?.(event)
}
}}
/>
{emojiInput.suggestions.map((suggestion) => (
<button
key={suggestion.shortcode}
type="button"
onClick={() => emojiInput.selectSuggestion(suggestion)}
>
:{suggestion.shortcode}:
</button>
))}
</div>
)
}
describe('useWorkspaceEmojiShortcodeInput', () => {
afterEach(cleanup)
it('replaces a completed shortcode without discarding trailing text', () => {
render(<EmojiInputHarness initialValue="Launch " />)
const input = screen.getByRole('textbox', { name: 'Workspace name' })
fireEvent.change(input, {
target: { value: 'Launch :wink: experiment', selectionStart: 13 }
})
expect((input as HTMLInputElement).value).toBe('Launch 😉 experiment')
})
it('opens suggestions and accepts the highlighted emoji with Enter', () => {
render(<EmojiInputHarness initialValue="Launch " />)
const input = screen.getByRole('textbox', { name: 'Workspace name' })
fireEvent.change(input, { target: { value: 'Launch :wink', selectionStart: 12 } })
expect(screen.getByText(':wink:')).toBeTruthy()
expect(input.parentElement?.dataset.emojiMenuOpen).toBe('true')
fireEvent.keyDown(input, { key: 'Enter' })
expect((input as HTMLInputElement).value).toBe('Launch 😉 ')
})
it('dismisses suggestions with Escape without clearing the query', () => {
render(<EmojiInputHarness initialValue="Launch " />)
const input = screen.getByRole('textbox', { name: 'Workspace name' })
fireEvent.change(input, { target: { value: 'Launch :wink', selectionStart: 12 } })
fireEvent.keyDown(input, { key: 'Escape' })
expect((input as HTMLInputElement).value).toBe('Launch :wink')
expect(input.parentElement?.dataset.emojiMenuOpen).toBe('false')
})
it('leaves composing arrow navigation to the IME', () => {
const onUnhandledKeyDown = vi.fn()
render(
<EmojiInputHarness initialValue="Launch :wink" onUnhandledKeyDown={onUnhandledKeyDown} />
)
const input = screen.getByRole('textbox', { name: 'Workspace name' })
;(input as HTMLInputElement).setSelectionRange(12, 12)
fireEvent.select(input)
const handled = fireEvent.keyDown(input, { key: 'ArrowDown', isComposing: true })
expect(handled).toBe(true)
expect(onUnhandledKeyDown).not.toHaveBeenCalled()
})
it('does not accept or submit on a composing Enter', () => {
const onUnhandledKeyDown = vi.fn()
render(
<EmojiInputHarness initialValue="Launch :wink" onUnhandledKeyDown={onUnhandledKeyDown} />
)
const input = screen.getByRole('textbox', { name: 'Workspace name' })
;(input as HTMLInputElement).setSelectionRange(12, 12)
fireEvent.select(input)
const handled = fireEvent.keyDown(input, { key: 'Enter', isComposing: true })
expect(handled).toBe(true)
expect(onUnhandledKeyDown).not.toHaveBeenCalled()
expect((input as HTMLInputElement).value).toBe('Launch :wink')
})
})

View File

@ -0,0 +1,158 @@
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import {
applyWorkspaceEmojiSuggestion,
getActiveWorkspaceEmojiShortcode,
replaceCompletedWorkspaceEmojiShortcode,
searchWorkspaceEmojiShortcodes,
type WorkspaceEmojiReplacement,
type WorkspaceEmojiSuggestion
} from '@/lib/workspace-emoji-shortcodes'
type WorkspaceEmojiShortcodeInputOptions = {
disabled?: boolean
inputRef: RefObject<HTMLInputElement | null>
onValueChange: (value: string) => void
value: string
}
export function useWorkspaceEmojiShortcodeInput({
disabled = false,
inputRef,
onValueChange,
value
}: WorkspaceEmojiShortcodeInputOptions) {
const [cursor, setCursor] = useState<number | null>(null)
const [commandValue, setCommandValue] = useState('')
const focusFrameRef = useRef<number | null>(null)
const activeShortcode = useMemo(
() => getActiveWorkspaceEmojiShortcode(value, cursor),
[cursor, value]
)
const suggestions = useMemo(
() => (activeShortcode ? searchWorkspaceEmojiShortcodes(activeShortcode.query) : []),
[activeShortcode]
)
const open = !disabled && activeShortcode !== null && suggestions.length > 0
const resolvedCommandValue = suggestions.some(
(suggestion) => `emoji:${suggestion.shortcode}` === commandValue
)
? commandValue
: suggestions[0]
? `emoji:${suggestions[0].shortcode}`
: ''
const selectedSuggestion =
suggestions.find((suggestion) => `emoji:${suggestion.shortcode}` === resolvedCommandValue) ??
null
const cancelFocusFrame = useCallback(() => {
if (focusFrameRef.current !== null) {
cancelAnimationFrame(focusFrameRef.current)
focusFrameRef.current = null
}
}, [])
useEffect(() => cancelFocusFrame, [cancelFocusFrame])
const applyReplacement = useCallback(
(replacement: WorkspaceEmojiReplacement) => {
onValueChange(replacement.value)
setCursor(null)
cancelFocusFrame()
focusFrameRef.current = requestAnimationFrame(() => {
focusFrameRef.current = null
inputRef.current?.focus({ preventScroll: true })
inputRef.current?.setSelectionRange(replacement.cursor, replacement.cursor)
})
},
[cancelFocusFrame, inputRef, onValueChange]
)
const handleValueChange = useCallback(
(
nextValue: string,
nextCursor: number | null = inputRef.current?.selectionStart ?? nextValue.length
) => {
const completedEmoji = replaceCompletedWorkspaceEmojiShortcode(nextValue, nextCursor)
if (completedEmoji) {
applyReplacement(completedEmoji)
return
}
onValueChange(nextValue)
setCursor(nextCursor)
},
[applyReplacement, inputRef, onValueChange]
)
const syncCursor = useCallback(
(input = inputRef.current) => setCursor(input?.selectionStart ?? null),
[inputRef]
)
const close = useCallback(() => setCursor(null), [])
const selectSuggestion = useCallback(
(suggestion: WorkspaceEmojiSuggestion) => {
if (!activeShortcode) {
return
}
applyReplacement(applyWorkspaceEmojiSuggestion(value, activeShortcode, suggestion))
},
[activeShortcode, applyReplacement, value]
)
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>): boolean => {
if (!open) {
return false
}
if (isImeCompositionKeyDown(event)) {
event.stopPropagation()
return true
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
event.stopPropagation()
const selectedIndex = suggestions.findIndex(
(suggestion) => `emoji:${suggestion.shortcode}` === resolvedCommandValue
)
const direction = event.key === 'ArrowDown' ? 1 : -1
const nextIndex = (selectedIndex + direction + suggestions.length) % suggestions.length
setCommandValue(`emoji:${suggestions[nextIndex].shortcode}`)
return true
}
const acceptsSuggestion =
(event.key === 'Enter' &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey) ||
(event.key === 'Tab' && !event.shiftKey)
if (acceptsSuggestion && selectedSuggestion) {
event.preventDefault()
event.stopPropagation()
selectSuggestion(selectedSuggestion)
return true
}
if (event.key === 'Escape') {
event.stopPropagation()
close()
return true
}
return false
},
[close, open, resolvedCommandValue, selectSuggestion, selectedSuggestion, suggestions]
)
return {
close,
commandValue: resolvedCommandValue,
handleKeyDown,
handleValueChange,
onCommandValueChange: setCommandValue,
open,
selectSuggestion,
suggestions,
syncCursor
}
}

View File

@ -52,6 +52,8 @@ vi.mock('@/components/cmd-j/palette-host-badge', () => ({
vi.mock('@/components/ui/command', async () => {
const React = await import('react')
return {
Command: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CommandGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CommandDialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) =>
open ? <div data-command-dialog="true">{children}</div> : null,
CommandInput: ({

View File

@ -0,0 +1,88 @@
import type { Page, TestInfo } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
async function captureProof(page: Page, testInfo: TestInfo, name: string): Promise<void> {
if (process.env.ORCA_E2E_RECORD_VIDEO === '1') {
return
}
const screenshotPath = testInfo.outputPath(name)
await page.screenshot({ path: screenshotPath })
await testInfo.attach(name, { path: screenshotPath, contentType: 'image/png' })
}
test.describe('Workspace emoji picker', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await orcaPage.waitForTimeout(750)
})
test('inserts emoji in sidebar rename, worktree details, and Cmd+J', async ({
orcaPage
}, testInfo) => {
const title = orcaPage.locator('[data-worktree-title-inline-rename=""]').first()
await expect(title).toBeVisible()
await title.dblclick()
const inlineInput = orcaPage.locator('[data-worktree-title-rename-input="true"]')
await expect(inlineInput).toBeVisible()
await inlineInput.fill('Sidebar proof')
await captureProof(orcaPage, testInfo, 'sidebar-rename-before.png')
await inlineInput.pressSequentially(' :wink', { delay: 60 })
const inlineSuggestions = orcaPage.locator('[data-workspace-emoji-suggestions="true"]')
await expect(inlineSuggestions.getByRole('option', { name: ':wink:' })).toBeVisible()
await captureProof(orcaPage, testInfo, 'sidebar-rename-picker.png')
await inlineInput.press('Enter')
await expect(inlineInput).toHaveValue('Sidebar proof 😉 ')
await inlineInput.press('Enter')
await expect(orcaPage.getByText('Sidebar proof 😉', { exact: true }).first()).toBeVisible()
await orcaPage.evaluate(() => {
const state = window.__store!.getState()
const worktree = Object.values(state.worktreesByRepo)
.flat()
.find((candidate) => candidate.id === state.activeWorktreeId)
if (!worktree) {
throw new Error('Active worktree not found')
}
state.openModal('edit-meta', {
worktreeId: worktree.id,
repoId: worktree.repoId,
currentDisplayName: worktree.displayName,
currentComment: worktree.comment,
focus: 'displayName'
})
})
const detailsDialog = orcaPage.getByRole('dialog', { name: 'Edit Worktree Details' })
const displayNameInput = detailsDialog.getByPlaceholder('Custom display name...')
await expect(displayNameInput).toBeFocused()
await displayNameInput.fill('Details proof')
await captureProof(orcaPage, testInfo, 'worktree-details-before.png')
await displayNameInput.pressSequentially(' :wink', { delay: 60 })
const detailsSuggestions = detailsDialog.locator('[data-workspace-emoji-suggestions="true"]')
await expect(detailsSuggestions.getByRole('option', { name: ':wink:' })).toBeVisible()
await captureProof(orcaPage, testInfo, 'worktree-details-picker.png')
await displayNameInput.press('Enter')
await expect(displayNameInput).toHaveValue('Details proof 😉 ')
await detailsDialog.getByRole('button', { name: 'Cancel' }).click()
await orcaPage.evaluate(() => window.__store!.getState().openModal('worktree-palette'))
const palette = orcaPage.getByRole('dialog', { name: 'Jump to...' })
const paletteInput = palette.getByPlaceholder(
'Search chats, terminals, worktrees, settings, and actions...'
)
await expect(paletteInput).toBeFocused()
await captureProof(orcaPage, testInfo, 'cmd-j-before.png')
await paletteInput.pressSequentially(':wink', { delay: 60 })
const paletteSuggestions = palette.locator('[data-workspace-emoji-suggestions="true"]')
await expect(paletteSuggestions.getByRole('option', { name: ':wink:' })).toBeVisible()
await captureProof(orcaPage, testInfo, 'cmd-j-picker.png')
await paletteInput.press('Enter')
await expect(paletteInput).toHaveValue('😉 ')
await expect(palette.getByText('Sidebar proof 😉', { exact: true }).first()).toBeVisible()
await orcaPage.waitForTimeout(750)
})
})