fix(raycast): preserve typed input over deferred selection (#1520)

This commit is contained in:
shamAnimates 2026-10-12 22:13:50 -07:00 • committed by Mahesh Sanikommu
parent 8b24b3aaf6
commit 6ff5b63a43
3 changed files with 120 additions and 37 deletions

View file

@ -8,8 +8,9 @@ import {
Icon,
getSelectedText,
} from "@raycast/api"
import { useState, useEffect } from "react"
import { useEffect, useRef, useState } from "react"
import { addMemory, fetchProjects } from "./api"
import { createDeferredPrefillOwner } from "./deferred-prefill"
import { usePromise } from "@raycast/utils"
import { withSupermemory } from "./withSupermemory"
@ -23,24 +24,18 @@ function Command() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [initialContent, setInitialContent] = useState("")
const { pop } = useNavigation()
const prefillOwnerRef = useRef<
ReturnType<typeof createDeferredPrefillOwner> | undefined
>(undefined)
prefillOwnerRef.current ??= createDeferredPrefillOwner(setInitialContent)
const prefillOwner = prefillOwnerRef.current
const { isLoading, data: projects = [] } = usePromise(fetchProjects)
useEffect(() => {
async function loadSelectedText() {
try {
const selectedText = await getSelectedText()
if (selectedText) {
setInitialContent(selectedText)
}
} catch {
// No text selected or error getting selected text - silently fail
// User can still manually enter content
}
}
loadSelectedText()
}, [])
const request = prefillOwner.start(getSelectedText)
return request.cancel
}, [prefillOwner])
async function handleSubmit(values: FormValues) {
if (!values.content.trim()) {
@ -91,7 +86,7 @@ function Command() {
value={initialContent}
placeholder="Enter the memory content..."
info="The main content of your memory. This is required."
onChange={(value) => setInitialContent(value)}
onChange={prefillOwner.updateFromUser}
/>
<Form.Separator />
<Form.Dropdown

View file

@ -0,0 +1,86 @@
export function createDeferredPrefillOwner(
applyValue: (value: string) => void,
) {
let prefillOwnsValue = true
return {
updateFromUser(value: string) {
prefillOwnsValue = false
applyValue(value)
},
start(readValue: () => Promise<string>) {
let active = true
const completion = (async () => {
try {
const value = await readValue()
if (active && prefillOwnsValue && value) {
applyValue(value)
}
} catch {
// Selection is unavailable; keep the current value unchanged.
}
})()
return {
completion,
cancel() {
active = false
},
}
},
}
}
export const SEARCH_QUERY_DEBOUNCE_MS = 300
type CancelScheduledQuery = () => void
type ScheduleQuery = (
callback: () => void,
delayMs: number,
) => CancelScheduledQuery
const scheduleQuery: ScheduleQuery = (callback, delayMs) => {
const timeout = setTimeout(callback, delayMs)
return () => clearTimeout(timeout)
}
export function createSearchInputAdapter(
applySearchText: (value: string) => void,
applySearchQuery: (value: string) => void,
schedule: ScheduleQuery = scheduleQuery,
) {
let cancelScheduledQuery: CancelScheduledQuery | undefined
const applyValue = (value: string) => {
applySearchText(value)
cancelScheduledQuery?.()
cancelScheduledQuery = undefined
if (!value.trim()) {
applySearchQuery(value)
return
}
cancelScheduledQuery = schedule(() => {
cancelScheduledQuery = undefined
applySearchQuery(value)
}, SEARCH_QUERY_DEBOUNCE_MS)
}
const prefillOwner = createDeferredPrefillOwner(applyValue)
return {
getListProps(searchText: string) {
return {
onSearchTextChange: prefillOwner.updateFromUser,
searchText,
throttle: false as const,
}
},
startPrefill: prefillOwner.start,
cancelPendingQuery() {
cancelScheduledQuery?.()
cancelScheduledQuery = undefined
},
}
}

View file

@ -9,8 +9,9 @@ import {
getSelectedText,
} from "@raycast/api"
import { usePromise } from "@raycast/utils"
import { useState, useEffect } from "react"
import { useEffect, useRef, useState } from "react"
import { searchMemories, type SearchResult } from "./api"
import { createSearchInputAdapter } from "./deferred-prefill"
import { formatRelevanceScore } from "./relevance-score"
import { withSupermemory } from "./withSupermemory"
@ -63,23 +64,25 @@ const truncateContent = (content: string, maxLength = 100) => {
}
export default withSupermemory(Command)
function Command() {
export function Command() {
const [searchText, setSearchText] = useState("")
const [searchQuery, setSearchQuery] = useState("")
const searchInputRef = useRef<
ReturnType<typeof createSearchInputAdapter> | undefined
>(undefined)
searchInputRef.current ??= createSearchInputAdapter(
setSearchText,
setSearchQuery,
)
const searchInput = searchInputRef.current
useEffect(() => {
async function loadSelectedText() {
try {
const selectedText = await getSelectedText()
if (selectedText) {
setSearchText(selectedText)
}
} catch {
// No text selected or error getting selected text - silently fail
}
const request = searchInput.startPrefill(getSelectedText)
return () => {
request.cancel()
searchInput.cancelPendingQuery()
}
loadSelectedText()
}, [])
}, [searchInput])
const { isLoading, data: searchResults = [] } = usePromise(
async (query: string) => {
@ -99,7 +102,7 @@ function Command() {
}
return results
},
[searchText],
[searchQuery],
)
const formatDate = (dateString: string) => {
@ -116,14 +119,13 @@ function Command() {
}
}
const hasSearched = !isLoading && !searchResults.length
const isSearching = isLoading || searchText !== searchQuery
const hasSearched = !isSearching && !searchResults.length
return (
<List
isLoading={isLoading}
onSearchTextChange={setSearchText}
searchText={searchText}
{...searchInput.getListProps(searchText)}
isLoading={isSearching}
searchBarPlaceholder="Search your memories..."
throttle
>
{hasSearched && !searchText.trim() ? (
<List.EmptyView
@ -137,7 +139,7 @@ function Command() {
title="No Memories Found"
description={`No memories found for "${searchText}"`}
/>
) : isLoading && searchText.trim() ? (
) : isSearching && searchText.trim() ? (
<List.EmptyView
icon={Icon.MagnifyingGlass}
title="Searching Your Memories"