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:
parent
6e63bbbb52
commit
9ccec550c3
|
|
@ -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: ({
|
||||
|
|
|
|||
|
|
@ -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('😉')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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[] = []
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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' } })
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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: ({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue