| ... | @@ -32,7 +32,11 @@ import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; | ... | @@ -32,7 +32,11 @@ import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; |
| 32 | | 32 | |
| 33 | import { isElectron } from "../env"; | 33 | import { isElectron } from "../env"; |
| 34 | import { buildBootstrapInput } from "../historyBootstrap"; | 34 | import { buildBootstrapInput } from "../historyBootstrap"; |
| 35 | import { detectComposerTrigger, replaceTextRange } from "../composer-logic"; | 35 | import { |
| | 36 | type ComposerTriggerKind, |
| | 37 | detectComposerTrigger, |
| | 38 | replaceTextRange, |
| | 39 | } from "../composer-logic"; |
| 36 | import { | 40 | import { |
| 37 | DEFAULT_MODEL, | 41 | DEFAULT_MODEL, |
| 38 | DEFAULT_REASONING, | 42 | DEFAULT_REASONING, |
| ... | @@ -102,6 +106,14 @@ const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES | ... | @@ -102,6 +106,14 @@ const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES |
| 102 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = | 106 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 103 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; | 107 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; |
| 104 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | 108 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| | 109 | const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; |
| | 110 | const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; |
| | 111 | const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({ |
| | 112 | slug, |
| | 113 | name, |
| | 114 | searchSlug: slug.toLowerCase(), |
| | 115 | searchName: name.toLowerCase(), |
| | 116 | })); |
| 105 | | 117 | |
| 106 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | 118 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 107 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; | 119 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; |
| ... | @@ -209,6 +221,102 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { | ... | @@ -209,6 +221,102 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { |
| 209 | ); | 221 | ); |
| 210 | }); | 222 | }); |
| 211 | | 223 | |
| | 224 | const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { |
| | 225 | item: ComposerCommandItem; |
| | 226 | index: number; |
| | 227 | isHighlighted: boolean; |
| | 228 | keyboardHighlight: boolean; |
| | 229 | resolvedTheme: "light" | "dark"; |
| | 230 | onHighlight: (index: number) => void; |
| | 231 | onSelect: (item: ComposerCommandItem) => void; |
| | 232 | }) { |
| | 233 | return ( |
| | 234 | <CommandItem |
| | 235 | value={props.item.id} |
| | 236 | data-composer-menu-index={props.index} |
| | 237 | className={cn( |
| | 238 | "cursor-pointer gap-2", |
| | 239 | props.keyboardHighlight && |
| | 240 | !props.isHighlighted && |
| | 241 | "data-highlighted:bg-transparent data-highlighted:text-inherit", |
| | 242 | props.isHighlighted && "bg-accent text-accent-foreground", |
| | 243 | )} |
| | 244 | onMouseEnter={() => { |
| | 245 | props.onHighlight(props.index); |
| | 246 | }} |
| | 247 | onMouseDown={(event) => { |
| | 248 | event.preventDefault(); |
| | 249 | }} |
| | 250 | onClick={() => { |
| | 251 | props.onSelect(props.item); |
| | 252 | }} |
| | 253 | > |
| | 254 | {props.item.type === "path" ? ( |
| | 255 | <VscodeEntryIcon |
| | 256 | pathValue={props.item.path} |
| | 257 | kind={props.item.pathKind} |
| | 258 | theme={props.resolvedTheme} |
| | 259 | /> |
| | 260 | ) : null} |
| | 261 | {props.item.type === "slash-command" ? ( |
| | 262 | <CursorIcon className="size-4 text-muted-foreground/80" /> |
| | 263 | ) : null} |
| | 264 | {props.item.type === "model" ? ( |
| | 265 | <Badge variant="outline" className="px-1.5 py-0 text-[10px]"> |
| | 266 | model |
| | 267 | </Badge> |
| | 268 | ) : null} |
| | 269 | <span className="truncate">{props.item.label}</span> |
| | 270 | <span className="truncate text-muted-foreground/70 text-xs">{props.item.description}</span> |
| | 271 | </CommandItem> |
| | 272 | ); |
| | 273 | }); |
| | 274 | |
| | 275 | const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { |
| | 276 | items: ComposerCommandItem[]; |
| | 277 | selectedIndex: number; |
| | 278 | keyboardHighlight: boolean; |
| | 279 | resolvedTheme: "light" | "dark"; |
| | 280 | isLoading: boolean; |
| | 281 | triggerKind: ComposerTriggerKind | null; |
| | 282 | onHighlight: (index: number) => void; |
| | 283 | onSelect: (item: ComposerCommandItem) => void; |
| | 284 | menuRef: RefObject<HTMLDivElement | null>; |
| | 285 | }) { |
| | 286 | return ( |
| | 287 | <Command> |
| | 288 | <div |
| | 289 | ref={props.menuRef} |
| | 290 | className="overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs" |
| | 291 | > |
| | 292 | <CommandList className="max-h-64"> |
| | 293 | {props.items.map((item, index) => ( |
| | 294 | <ComposerCommandMenuItem |
| | 295 | key={item.id} |
| | 296 | item={item} |
| | 297 | index={index} |
| | 298 | isHighlighted={index === props.selectedIndex} |
| | 299 | keyboardHighlight={props.keyboardHighlight} |
| | 300 | resolvedTheme={props.resolvedTheme} |
| | 301 | onHighlight={props.onHighlight} |
| | 302 | onSelect={props.onSelect} |
| | 303 | /> |
| | 304 | ))} |
| | 305 | </CommandList> |
| | 306 | {props.items.length === 0 && ( |
| | 307 | <p className="px-3 py-2 text-muted-foreground/70 text-xs"> |
| | 308 | {props.isLoading |
| | 309 | ? "Searching workspace files..." |
| | 310 | : props.triggerKind === "path" |
| | 311 | ? "No matching files or folders." |
| | 312 | : "No matching command."} |
| | 313 | </p> |
| | 314 | )} |
| | 315 | </div> |
| | 316 | </Command> |
| | 317 | ); |
| | 318 | }); |
| | 319 | |
| 212 | export default function ChatView() { | 320 | export default function ChatView() { |
| 213 | const { state, dispatch } = useStore(); | 321 | const { state, dispatch } = useStore(); |
| 214 | const api = useNativeApi(); | 322 | const api = useNativeApi(); |
| ... | @@ -231,6 +339,7 @@ export default function ChatView() { | ... | @@ -231,6 +339,7 @@ export default function ChatView() { |
| 231 | const [nowTick, setNowTick] = useState(() => Date.now()); | 339 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 232 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); | 340 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 233 | const [composerCursor, setComposerCursor] = useState(0); | 341 | const [composerCursor, setComposerCursor] = useState(0); |
| | 342 | const [debouncedPathQuery, setDebouncedPathQuery] = useState(""); |
| 234 | const [composerMenuIndex, setComposerMenuIndex] = useState(0); | 343 | const [composerMenuIndex, setComposerMenuIndex] = useState(0); |
| 235 | const [composerKeyboardHighlight, setComposerKeyboardHighlight] = useState(false); | 344 | const [composerKeyboardHighlight, setComposerKeyboardHighlight] = useState(false); |
| 236 | const messagesScrollRef = useRef<HTMLDivElement>(null); | 345 | const messagesScrollRef = useRef<HTMLDivElement>(null); |
| ... | @@ -349,34 +458,55 @@ export default function ChatView() { | ... | @@ -349,34 +458,55 @@ export default function ChatView() { |
| 349 | () => detectComposerTrigger(prompt, composerCursor), | 458 | () => detectComposerTrigger(prompt, composerCursor), |
| 350 | [prompt, composerCursor], | 459 | [prompt, composerCursor], |
| 351 | ); | 460 | ); |
| | 461 | const composerTriggerKind = composerTrigger?.kind ?? null; |
| | 462 | const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; |
| | 463 | const isPathTrigger = composerTriggerKind === "path"; |
| 352 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); | 464 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); |
| 353 | const keybindingsQuery = useQuery({ | 465 | const keybindingsQuery = useQuery({ |
| 354 | ...serverConfigQueryOptions(api), | 466 | ...serverConfigQueryOptions(api), |
| 355 | select: (config) => config.keybindings, | 467 | select: (config) => config.keybindings, |
| 356 | }); | 468 | }); |
| | 469 | useEffect(() => { |
| | 470 | if (!isPathTrigger) { |
| | 471 | setDebouncedPathQuery(""); |
| | 472 | return; |
| | 473 | } |
| | 474 | if (pathTriggerQuery.length === 0) { |
| | 475 | setDebouncedPathQuery(""); |
| | 476 | return; |
| | 477 | } |
| | 478 | const timer = window.setTimeout(() => { |
| | 479 | setDebouncedPathQuery(pathTriggerQuery); |
| | 480 | }, COMPOSER_PATH_QUERY_DEBOUNCE_MS); |
| | 481 | return () => { |
| | 482 | window.clearTimeout(timer); |
| | 483 | }; |
| | 484 | }, [isPathTrigger, pathTriggerQuery]); |
| 357 | const workspaceEntriesQuery = useQuery({ | 485 | const workspaceEntriesQuery = useQuery({ |
| 358 | queryKey: [ | 486 | queryKey: [ |
| 359 | "composer-workspace-entries", | 487 | "composer-workspace-entries", |
| 360 | gitCwd, | 488 | gitCwd, |
| 361 | composerTrigger?.kind === "path" ? composerTrigger.query : "", | 489 | debouncedPathQuery, |
| 362 | ], | 490 | ], |
| 363 | enabled: Boolean(api && gitCwd && composerTrigger?.kind === "path"), | 491 | enabled: Boolean(api && gitCwd && isPathTrigger), |
| 364 | staleTime: 15_000, | 492 | staleTime: 15_000, |
| | 493 | placeholderData: (previous) => previous, |
| 365 | queryFn: async () => { | 494 | queryFn: async () => { |
| 366 | if (!api || !gitCwd || composerTrigger?.kind !== "path") { | 495 | if (!api || !gitCwd || !isPathTrigger) { |
| 367 | return { entries: [], truncated: false }; | 496 | return { entries: [], truncated: false }; |
| 368 | } | 497 | } |
| 369 | return api.projects.searchEntries({ | 498 | return api.projects.searchEntries({ |
| 370 | cwd: gitCwd, | 499 | cwd: gitCwd, |
| 371 | query: composerTrigger.query, | 500 | query: debouncedPathQuery, |
| 372 | limit: 80, | 501 | limit: 80, |
| 373 | }); | 502 | }); |
| 374 | }, | 503 | }, |
| 375 | }); | 504 | }); |
| | 505 | const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; |
| 376 | const composerMenuItems = useMemo<ComposerCommandItem[]>(() => { | 506 | const composerMenuItems = useMemo<ComposerCommandItem[]>(() => { |
| 377 | if (!composerTrigger) return []; | 507 | if (!composerTrigger) return []; |
| 378 | if (composerTrigger.kind === "path") { | 508 | if (composerTrigger.kind === "path") { |
| 379 | return (workspaceEntriesQuery.data?.entries ?? []).map((entry) => ({ | 509 | return workspaceEntries.map((entry) => ({ |
| 380 | id: `path:${entry.kind}:${entry.path}`, | 510 | id: `path:${entry.kind}:${entry.path}`, |
| 381 | type: "path", | 511 | type: "path", |
| 382 | path: entry.path, | 512 | path: entry.path, |
| ... | @@ -400,10 +530,10 @@ export default function ChatView() { | ... | @@ -400,10 +530,10 @@ export default function ChatView() { |
| 400 | ]; | 530 | ]; |
| 401 | } | 531 | } |
| 402 | | 532 | |
| 403 | return MODEL_OPTIONS.filter(({ slug, name }) => { | 533 | return SEARCHABLE_MODEL_OPTIONS.filter(({ searchSlug, searchName }) => { |
| 404 | const query = composerTrigger.query.trim().toLowerCase(); | 534 | const query = composerTrigger.query.trim().toLowerCase(); |
| 405 | if (!query) return true; | 535 | if (!query) return true; |
| 406 | return slug.toLowerCase().includes(query) || name.toLowerCase().includes(query); | 536 | return searchSlug.includes(query) || searchName.includes(query); |
| 407 | }).map(({ slug, name }) => ({ | 537 | }).map(({ slug, name }) => ({ |
| 408 | id: `model:${slug}`, | 538 | id: `model:${slug}`, |
| 409 | type: "model", | 539 | type: "model", |
| ... | @@ -411,7 +541,7 @@ export default function ChatView() { | ... | @@ -411,7 +541,7 @@ export default function ChatView() { |
| 411 | label: name, | 541 | label: name, |
| 412 | description: slug, | 542 | description: slug, |
| 413 | })); | 543 | })); |
| 414 | }, [composerTrigger, workspaceEntriesQuery.data?.entries]); | 544 | }, [composerTrigger, workspaceEntries]); |
| 415 | const composerMenuOpen = Boolean(composerTrigger); | 545 | const composerMenuOpen = Boolean(composerTrigger); |
| 416 | const activeComposerMenuItem = | 546 | const activeComposerMenuItem = |
| 417 | composerMenuItems[Math.min(composerMenuIndex, Math.max(0, composerMenuItems.length - 1))] ?? | 547 | composerMenuItems[Math.min(composerMenuIndex, Math.max(0, composerMenuItems.length - 1))] ?? |
| ... | @@ -611,7 +741,7 @@ export default function ChatView() { | ... | @@ -611,7 +741,7 @@ export default function ChatView() { |
| 611 | `[data-composer-menu-index="${aheadIndex}"]`, | 741 | `[data-composer-menu-index="${aheadIndex}"]`, |
| 612 | ); | 742 | ); |
| 613 | node?.scrollIntoView({ block: "nearest" }); | 743 | node?.scrollIntoView({ block: "nearest" }); |
| 614 | }, [composerMenuIndex, composerMenuItems, composerMenuOpen]); | 744 | }, [composerMenuIndex, composerMenuItems.length, composerMenuOpen]); |
| 615 | | 745 | |
| 616 | useEffect(() => { | 746 | useEffect(() => { |
| 617 | if (!activeThread?.id || activeThread.terminalOpen) return; | 747 | if (!activeThread?.id || activeThread.terminalOpen) return; |
| ... | @@ -1165,6 +1295,12 @@ export default function ChatView() { | ... | @@ -1165,6 +1295,12 @@ export default function ChatView() { |
| 1165 | }, | 1295 | }, |
| 1166 | [applyPromptReplacement, composerTrigger, onModelSelect], | 1296 | [applyPromptReplacement, composerTrigger, onModelSelect], |
| 1167 | ); | 1297 | ); |
| | 1298 | const onHighlightComposerMenuItem = useCallback((index: number) => { |
| | 1299 | setComposerKeyboardHighlight(false); |
| | 1300 | setComposerMenuIndex(index); |
| | 1301 | }, []); |
| | 1302 | const isComposerMenuLoading = |
| | 1303 | composerTriggerKind === "path" && (workspaceEntriesQuery.isLoading || workspaceEntriesQuery.isFetching); |
| 1168 | | 1304 | |
| 1169 | const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => { | 1305 | const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => { |
| 1170 | setPrompt(nextPrompt); | 1306 | setPrompt(nextPrompt); |
| ... | @@ -1293,67 +1429,17 @@ export default function ChatView() { | ... | @@ -1293,67 +1429,17 @@ export default function ChatView() { |
| 1293 | <div className="relative px-4 pt-4 pb-2"> | 1429 | <div className="relative px-4 pt-4 pb-2"> |
| 1294 | {composerMenuOpen && ( | 1430 | {composerMenuOpen && ( |
| 1295 | <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1"> | 1431 | <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1"> |
| 1296 | <Command> | 1432 | <ComposerCommandMenu |
| 1297 | <div | 1433 | items={composerMenuItems} |
| 1298 | ref={composerMenuRef} | 1434 | selectedIndex={composerMenuIndex} |
| 1299 | className="overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs" | 1435 | keyboardHighlight={composerKeyboardHighlight} |
| 1300 | > | 1436 | resolvedTheme={resolvedTheme} |
| 1301 | <CommandList className="max-h-64"> | 1437 | isLoading={isComposerMenuLoading} |
| 1302 | {composerMenuItems.map((item, index) => ( | 1438 | triggerKind={composerTriggerKind} |
| 1303 | <CommandItem | 1439 | onHighlight={onHighlightComposerMenuItem} |
| 1304 | key={item.id} | 1440 | onSelect={onSelectComposerItem} |
| 1305 | value={item.id} | 1441 | menuRef={composerMenuRef} |
| 1306 | data-composer-menu-index={index} | 1442 | /> |
| 1307 | className={cn( | | |
| 1308 | "cursor-pointer gap-2", | | |
| 1309 | composerKeyboardHighlight && | | |
| 1310 | "data-highlighted:bg-transparent data-highlighted:text-inherit", | | |
| 1311 | index === composerMenuIndex && "bg-accent text-accent-foreground", | | |
| 1312 | )} | | |
| 1313 | onMouseEnter={() => { | | |
| 1314 | setComposerKeyboardHighlight(false); | | |
| 1315 | setComposerMenuIndex(index); | | |
| 1316 | }} | | |
| 1317 | onMouseDown={(event) => { | | |
| 1318 | event.preventDefault(); | | |
| 1319 | }} | | |
| 1320 | onClick={() => { | | |
| 1321 | onSelectComposerItem(item); | | |
| 1322 | }} | | |
| 1323 | > | | |
| 1324 | {item.type === "path" ? ( | | |
| 1325 | <VscodeEntryIcon | | |
| 1326 | pathValue={item.path} | | |
| 1327 | kind={item.pathKind} | | |
| 1328 | theme={resolvedTheme} | | |
| 1329 | /> | | |
| 1330 | ) : null} | | |
| 1331 | {item.type === "slash-command" ? ( | | |
| 1332 | <CursorIcon className="size-4 text-muted-foreground/80" /> | | |
| 1333 | ) : null} | | |
| 1334 | {item.type === "model" ? ( | | |
| 1335 | <Badge variant="outline" className="px-1.5 py-0 text-[10px]"> | | |
| 1336 | model | | |
| 1337 | </Badge> | | |
| 1338 | ) : null} | | |
| 1339 | <span className="truncate">{item.label}</span> | | |
| 1340 | <span className="truncate text-muted-foreground/70 text-xs"> | | |
| 1341 | {item.description} | | |
| 1342 | </span> | | |
| 1343 | </CommandItem> | | |
| 1344 | ))} | | |
| 1345 | </CommandList> | | |
| 1346 | {composerMenuItems.length === 0 && ( | | |
| 1347 | <p className="px-3 py-2 text-muted-foreground/70 text-xs"> | | |
| 1348 | {workspaceEntriesQuery.isLoading | | |
| 1349 | ? "Searching workspace files..." | | |
| 1350 | : composerTrigger?.kind === "path" | | |
| 1351 | ? "No matching files or folders." | | |
| 1352 | : "No matching command."} | | |
| 1353 | </p> | | |
| 1354 | )} | | |
| 1355 | </div> | | |
| 1356 | </Command> | | |
| 1357 | </div> | 1443 | </div> |
| 1358 | )} | 1444 | )} |
| 1359 | | 1445 | |